From a8fa3f908cf71af1ac2b2c8e614ab2f184f782bd Mon Sep 17 00:00:00 2001 From: LocoAgent Date: Wed, 5 Aug 2026 11:03:28 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=83=D0=B3=D1=80=D0=BE=D0=B7=D1=8B=20?= =?UTF-8?q?=E2=80=94=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8E=20=D0=A3=D0=91?= =?UTF-8?q?=D0=98=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81=D0=B5=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BE=D1=82=20=D0=98=D0=98=20(=D0=BC=D1=83=D1=81=D0=BE=D1=80?= =?UTF-8?q?=20=D0=BE=D1=82=D0=B1=D1=80=D0=B0=D1=81=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F,=20=D0=A3=D0=91=D0=98.069=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC),=20=C2=AB?= =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=BE=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D1=83?= =?UTF-8?q?=D0=B3=D1=80=D0=BE=D0=B7=D1=8B=C2=BB=20=E2=80=94=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=81=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20(=D1=81=D0=B8=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC?= =?UTF-8?q?=D0=B0,=20=D0=BA=D1=80=D0=B0=D1=81=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=E2=80=94=20=D0=98=D0=98,=20=D0=B7=D0=B5=D0=BB=D1=91=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=E2=80=94=20=D1=81=D0=BE=D0=B2=D0=BF=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE),=20=C2=AB=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=98=D0=98?= =?UTF-8?q?=C2=BB=20=E2=80=94=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82?= =?UTF-8?q?=20+=20=D1=82=D0=BE=D1=87=D0=B5=D1=87=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8E,=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=97=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=B8=D1=82=D1=8C=20=D1=83=D0=B3=D1=80=D0=BE?= =?UTF-8?q?=D0=B7=D1=8B=20=D0=A4=D0=A1=D0=A2=D0=AD=D0=9A=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dokogen/ui.py | 253 +++++++++++++++++++++++++++++++++------------- dokogen/verify.py | 70 ++++++++++++- 2 files changed, 251 insertions(+), 72 deletions(-) diff --git a/dokogen/ui.py b/dokogen/ui.py index 02e0e6a..f918fcb 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -1092,8 +1092,10 @@ class DokoGenApp: command=self._suggest_threats_ai_click).pack(side=tk.LEFT, padx=2) ttk.Button(thr_actions, text="🤖 Проверить через ИИ", command=self._check_threats_ai_click).pack(side=tk.LEFT, padx=2) - ttk.Button(thr_actions, text="⚖️ Сравнить", - command=self._compare_threats).pack(side=tk.LEFT, padx=2) + ttk.Button(thr_actions, text="🎯 Подобрать угрозы (программа+ИИ)", + command=self._open_threats_picker).pack(side=tk.LEFT, padx=2) + ttk.Button(thr_actions, text="⬇️ Загрузить угрозы ФСТЭК", + command=self._download_fstec_threats).pack(side=tk.LEFT, padx=2) self._threats_date_label = ttk.Label(thr_actions, text="", foreground='gray') self._threats_date_label.pack(side=tk.LEFT, padx=8) @@ -2969,13 +2971,12 @@ class DokoGenApp: if not threats: self._verify_log("❌ ИИ не вернул угрозы") return - lb = self.is_threats_listbox - lb.delete(0, tk.END) - for t in threats: - lb.insert(tk.END, t) - self._collect_is() # сохранить в модель — галочки в словаре будут стоять - self.log(f"🤖 ИИ подобрал угроз: {len(threats)}") - self._verify_log(f"🤖 ИИ: {len(threats)} угроз") + # Запоминаем рекомендации ИИ (уже отфильтрованы по словарю УБИ) — + # не заменяем список молча, а открываем окно подбора с галочками. + self._last_ai_threats = list(threats) + self.log(f"🤖 ИИ рекомендует угроз: {len(threats)}") + self._verify_log(f"🤖 ИИ: {len(threats)} угроз (отфильтровано по словарю УБИ)") + self._open_threats_picker() def _ask_amount(self, parent, title, prompt, initial=''): """Свой диалог ввода суммы: принимает «121 118 270 руб», «1 000» и т.п. @@ -3032,23 +3033,25 @@ class DokoGenApp: parent.wait_window(dlg) return result['val'] - def _compare_threats(self): - """Сравнить угрозы, подобранные программой и ИИ.""" + def _open_threats_picker(self): + """🎯 Окно подбора угроз: программа (синий) + ИИ (красный) + совпавшие (зелёный). + + Оператор отмечает галочками нужные угрозы (как в словаре) и жмёт + «✅ Применить выбранные» — список угроз заменяется выбранными. + По умолчанию отмечены все подобранные (программа + ИИ + текущие), + оператор снимает галочки с неугодных. + """ if self._current_is_idx is None: - messagebox.showinfo("Сравнение", "Сначала выберите объект КИИ в списке слева") + messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева") return self._collect_is() isys = self.model.information_systems[self._current_is_idx] from .threat_rules import suggest_threats_program program = suggest_threats_program(isys) current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())] - if not current: - messagebox.showinfo("Сравнение", "Сначала подберите угрозы (программой или ИИ).") - return + ai = list(getattr(self, '_last_ai_threats', None) or []) - # Нормализация строк угроз для сравнения: - # «1. УБИ.001 Угроза…», «УБИ.001 Угроза…» и «УБИ.001 Угроза…» — это одна и та же угроза. - # Ключ сравнения — код УБИ, если он есть (ИИ может обрезать/перефразировать название); + # Нормализация строк угроз: ключ сравнения — код УБИ (если есть), # иначе — нормализованный текст. def _ubi_code(s): m = re.search(r'уби\.?\s*(\d+)', str(s).lower()) @@ -3063,60 +3066,117 @@ class DokoGenApp: code = _ubi_code(s) return ('ubi', code) if code else ('name', _norm(s)) - pset_norm = {_key(t) for t in program} - cset_norm = {_key(t) for t in current} - - # Сортировка обоих списков по коду УБИ — чтобы совпадающие угрозы - # были на одних строках (удобно сверять). - def _sort_key(t): - code = _ubi_code(t) - return (0, int(code)) if code else (1, _norm(t)) - program = sorted(program, key=_sort_key) - current = sorted(current, key=_sort_key) - - # Показываем окно: слева подобрано программой, справа текущий список - dlg = tk.Toplevel(self.root) - dlg.title("Сравнение угроз: программа ↔ ИИ/текущий") - dlg.geometry("1400x700") - dlg.transient(self.root) - - frm = ttk.Frame(dlg, padding=8) - frm.pack(fill=tk.BOTH, expand=True) - - # Два списка на ~95% ширины окна (по ~47% + отступы) - left = ttk.Frame(frm) - left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - ttk.Label(left, text=f"⚙️ Программа ({len(program)}) — отсортировано по УБИ", - font=('Arial', 9, 'bold')).pack(anchor=tk.W) - lb_p = tk.Listbox(left, height=22, width=70, font=('Courier', 9)) - lb_p.pack(fill=tk.BOTH, expand=True, pady=4) + # Объединяем все источники: key → {text, src: {program, ai, current}} + merged = {} + def _add(t, src): + if not str(t or '').strip(): + return + k = _key(t) + if k not in merged: + merged[k] = {'text': str(t).strip(), 'src': set()} + merged[k]['src'].add(src) for t in program: - lb_p.insert(tk.END, t) - - right = ttk.Frame(frm) - right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0)) - ttk.Label(right, text=f"🤖 ИИ / текущий ({len(current)}) — отсортировано по УБИ", - font=('Arial', 9, 'bold')).pack(anchor=tk.W) - lb_c = tk.Listbox(right, height=22, width=70, font=('Courier', 9)) - lb_c.pack(fill=tk.BOTH, expand=True, pady=4) + _add(t, 'program') for t in current: - lb_c.insert(tk.END, t) + _add(t, 'current') + for t in ai: + _add(t, 'ai') - # Подсветка совпадений (по коду УБИ / нормализованному виду) - common = pset_norm & cset_norm - for i in range(lb_p.size()): - if _key(lb_p.get(i)) in common: - lb_p.itemconfig(i, bg='#c8e6c9') - for i in range(lb_c.size()): - if _key(lb_c.get(i)) in common: - lb_c.itemconfig(i, bg='#c8e6c9') + items = sorted(merged.values(), + key=lambda v: (0, int(_ubi_code(v['text']))) + if _ubi_code(v['text']) else (1, _norm(v['text']))) - info = ttk.Label(frm, - text=f"Совпало угроз: {len(common)} (зелёным). " - f"Только программа: {len(program) - len(common)}, " - f"только ИИ/текущий: {len(current) - len(common)}.", - foreground='gray') - info.pack(anchor=tk.W, pady=(4, 0)) + # Окно с чекбоксами (как выбор из словаря) + dlg = tk.Toplevel(self.root) + dlg.title(f"Подбор угроз: {isys.name or ''}") + dlg.geometry("980x700") + dlg.transient(self.root) + dlg.grab_set() + + frame = ttk.Frame(dlg, padding=10) + frame.pack(fill=tk.BOTH, expand=True) + + legend = ttk.Label(frame, + text="🟦 синий — рекомендация программы | 🟥 красный — рекомендация ИИ | " + "🟩 зелёный — совпало | ⬜ белый — уже было в списке", + foreground='gray') + legend.pack(anchor=tk.W, pady=(0, 5)) + + # Поиск + search_var = tk.StringVar() + search_entry = ttk.Entry(frame, textvariable=search_var) + search_entry.pack(fill=tk.X, pady=(0, 5)) + search_entry.focus_set() + + canvas = tk.Canvas(frame, highlightthickness=0) + scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview) + check_frame = ttk.Frame(canvas) + check_frame.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=check_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + vars = {} + widgets = {} + for v in items: + src = v['src'] + has_p = 'program' in src + has_a = 'ai' in src + if has_p and has_a: + bg = '#c8e6c9' # зелёный — совпало + elif has_p: + bg = '#b3d4fc' # синий — только программа + elif has_a: + bg = '#ffd0d0' # красный — только ИИ + else: + bg = '#ffffff' # белый — только текущий список + var = tk.BooleanVar(value=True) # по умолчанию отмечено всё + cb = tk.Checkbutton(check_frame, text=v['text'], variable=var, anchor=tk.W, + bg=bg, activebackground=bg, selectcolor='white', + justify=tk.LEFT, wraplength=900) + cb.pack(fill=tk.X, pady=1) + vars[v['text']] = var + widgets[v['text']] = cb + + def _apply_search(*_): + q = search_var.get().strip().lower() + for text, cb in widgets.items(): + if q and q not in text.lower(): + cb.pack_forget() + else: + cb.pack(fill=tk.X, pady=1) + search_var.trace_add('write', _apply_search) + + btn_frame = ttk.Frame(dlg, padding=10) + btn_frame.pack(fill=tk.X) + + def _select_all(): + for var in vars.values(): + var.set(True) + + def _clear_all(): + for var in vars.values(): + var.set(False) + + def _apply(): + selected = [t for t, var in vars.items() if var.get()] + selected.sort(key=lambda s: (0, int(_ubi_code(s))) + if _ubi_code(s) else (1, _norm(s))) + lb = self.is_threats_listbox + lb.delete(0, tk.END) + for t in selected: + lb.insert(tk.END, t) + self._collect_is() + self.log(f"🎯 Применено угроз: {len(selected)}") + dlg.destroy() + + ttk.Button(btn_frame, text="Выбрать всё", command=_select_all).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="Снять всё", command=_clear_all).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="✅ Применить выбранные", command=_apply).pack(side=tk.RIGHT, padx=2) + ttk.Button(btn_frame, text="Отмена", command=dlg.destroy).pack(side=tk.RIGHT, padx=2) + + dlg.wait_window() def _check_threats_ai_click(self): """🤖 Проверить текущий список угроз через ИИ (не подбирать заново). @@ -3200,14 +3260,65 @@ class DokoGenApp: dlg = tk.Toplevel(self.root) dlg.title(f"Проверка угроз ИИ — {obj_name}") - dlg.geometry("820x560") + dlg.geometry("860x600") dlg.transient(self.root) txt = tk.Text(dlg, wrap=tk.WORD, font=('Arial', 10)) txt.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) txt.insert('1.0', '\n'.join(lines)) txt.config(state=tk.DISABLED) - ttk.Button(dlg, text="Закрыть", - command=dlg.destroy).pack(pady=(0, 8)) + + def _ubi_code(s): + m = re.search(r'уби\.?\s*(\d+)', str(s).lower()) + return m.group(1) if m else None + + def _add_missing(): + """Добавить недостающие (только из словаря УБИ, полные названия).""" + lb = self.is_threats_listbox + existing = {(_ubi_code(lb.get(i)) or lb.get(i).strip().lower()) + for i in range(lb.size())} + added = 0 + for t in missing: + code = _ubi_code(t) + key = code or str(t).strip().lower() + if key in existing: + continue + lb.insert(tk.END, t) + existing.add(key) + added += 1 + if added: + self._collect_is() + self.log(f"➕ Добавлено недостающих угроз (по словарю): {added}") + else: + self.log("➕ Недостающие уже есть в списке — ничего не добавлено") + dlg.destroy() + + def _remove_extra(): + """Убрать лишние (только те, что реально есть в списке, по коду УБИ).""" + lb = self.is_threats_listbox + extra_codes = {c for c in (_ubi_code(t) for t in extra) if c} + removed = 0 + for i in range(lb.size() - 1, -1, -1): + code = _ubi_code(lb.get(i)) + if code and code in extra_codes: + lb.delete(i) + removed += 1 + if removed: + self._collect_is() + self.log(f"➖ Убрано лишних угроз: {removed}") + else: + self.log("➖ Лишние не найдены в списке — ничего не убрано") + dlg.destroy() + + btns = ttk.Frame(dlg) + btns.pack(pady=(0, 8)) + if missing: + ttk.Button(btns, text="➕ Добавить недостающие (по словарю)", + command=_add_missing).pack(side=tk.LEFT, padx=4) + if extra: + ttk.Button(btns, text="➖ Убрать лишние", + command=_remove_extra).pack(side=tk.LEFT, padx=4) + ttk.Button(btns, text="Закрыть", + command=dlg.destroy).pack(side=tk.LEFT, padx=4) self.log(f"🤖 Проверка угроз ИИ: лишних {len(extra)}, недостающих {len(missing)}") self._verify_log('\n'.join(lines)) diff --git a/dokogen/verify.py b/dokogen/verify.py index 94e110d..d8fac72 100644 --- a/dokogen/verify.py +++ b/dokogen/verify.py @@ -525,6 +525,66 @@ def compare_company_values(old_data, new_data): return diffs +def _ubi_code(text): + """Извлекает код УБИ из строки: «УБИ.069 Название» → '069'.""" + m = re.search(r'уби\.?\s*(\d+)', str(text), re.IGNORECASE) + return m.group(1) if m else None + + +def _norm_threat_text(text): + """Нормализация названия угрозы для сравнения: без ведущего номера, лишних пробелов.""" + s = re.sub(r'^\d+[.)]?\s*', '', str(text or '').strip()) + s = re.sub(r'\s+', ' ', s).strip() + return s.lower() + + +def filter_threats_by_dict(lines, threats_full=None): + """Приводит строки угроз (от ИИ или оператора) к каноническому виду по словарю УБИ. + + Для каждой строки: + - есть код УБИ → берём полную запись «УБИ.xxx Название» из словаря; + - кода нет → ищем по названию (точное совпадение или вхождение); + - не нашлось в словаре → отбрасываем (мусор от ИИ). + + Возвращает уникальные канонические строки, отсортированные по коду УБИ. + """ + if threats_full is None: + from .threat_rules import load_threats + threats_full = load_threats() + by_code = {} + by_norm = {} + for t in threats_full: + t = str(t).strip() + code = _ubi_code(t) + if code: + by_code.setdefault(code, t) + by_norm.setdefault(_norm_threat_text(t), t) + out = [] + seen = set() + for line in lines or []: + line = str(line or '').strip() + if not line: + continue + code = _ubi_code(line) + canon = None + if code and code in by_code: + canon = by_code[code] + else: + n = _norm_threat_text(line) + if n in by_norm: + canon = by_norm[n] + else: + for k, v in by_norm.items(): + if n and (n in k or k in n): + canon = v + break + if canon and canon not in seen: + seen.add(canon) + out.append(canon) + out.sort(key=lambda t: (int(_ubi_code(t)) if _ubi_code(t) else 10 ** 9, t)) + return out + + def suggest_threats_ai(security_data, api_key, model='deepseek-chat', base_url=None, threats=None): """ИИ предлагает актуальные угрозы БИ для объекта КИИ по его характеристикам. @@ -571,6 +631,9 @@ def suggest_threats_ai(security_data, api_key, model='deepseek-chat', base_url=N if isinstance(threats_out, str): threats_out = [threats_out] threats_out = [str(t).strip() for t in threats_out if str(t).strip()] + # Жёсткий фильтр по словарю: мусор отбрасываем, «УБИ.069» дополняем + # полным названием из актуального перечня (если он передан). + threats_out = filter_threats_by_dict(threats_out, threats) return threats_out, None @@ -606,7 +669,12 @@ def check_threats_ai(security_data, current_threats, api_key, model='deepseek-ch js, err = _ai_json(api_key, model, base_url, system, user) if err: return None, err - return js, None + # «Недостающие»/«лишние» — тоже через словарь: мусор отбрасываем, + # неполные строки («УБИ.069») дополняем полными названиями. + missing = filter_threats_by_dict(js.get('недостающие') or []) + extra = filter_threats_by_dict(js.get('лишние') or []) + comment = js.get('комментарий') or '' + return {'лишние': extra, 'недостающие': missing, 'комментарий': comment}, None def suggest_object_params_ai(object_data, api_key, model='deepseek-chat', base_url=None,