diff --git a/dokogen/ui.py b/dokogen/ui.py index 686e1e1..02e0e6a 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -761,9 +761,12 @@ class DokoGenApp: self._company_entries['okved'].insert(0, resolve_okved(comp['okved'], _tok3)) if want('okved_extra') and comp.get('okved_extra') and hasattr(self, 'okved_extra_listbox'): self.okved_extra_listbox.delete(0, tk.END) + _applied = 0 for code in comp['okved_extra']: if str(code).strip(): self.okved_extra_listbox.insert(tk.END, resolve_okved(str(code).strip(), _tok3)) + _applied += 1 + self.log(f"✅ Доп. ОКВЭД из DaData: применено {_applied} (всего в ответе: {len(comp['okved_extra'])})") if want('chief'): if comp.get('chief_position') and 'chief_position' in self._official_entries: self._official_entries['chief_position'].delete(0, tk.END) @@ -1087,6 +1090,8 @@ class DokoGenApp: command=self._suggest_threats_program_click).pack(side=tk.LEFT, padx=2) ttk.Button(thr_actions, text="🤖 Подобрать через ИИ", 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) self._threats_date_label = ttk.Label(thr_actions, text="", foreground='gray') @@ -2245,14 +2250,24 @@ class DokoGenApp: e_profit = _row("Прибыль субъекта КИИ за прошедший год (руб.):", 'profit', profit) e_tax = _row("Налог на прибыль за прошедший год (руб.):", 'tax', profit_tax) - e_dincome = _row("Снижение уровня доходов ΔДоходов (руб., наихудший сценарий):", 'dincome', '') - ttk.Label(frm, text="⚠️ ΔДоходов — экспертная оценка снижения доходов субъекта при наихудшем сценарии " - "нарушения (в опроснике её нет, вводится вручную; можно приравнять к ущербу из показателя 8).", - foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) - e_budget = _row("Доход федерального бюджета, усреднённый за 3 года Бусредн (руб.):", 'budget', + # ΔДоходов: если есть балансовая стоимость объекта КИИ — предзаполняем её + _bv9 = getattr(isys, 'balance_value', '') or '' + _bvn9 = _num(_bv9) + e_dincome = _row("Снижение уровня доходов ΔДоходов (руб., наихудший сценарий):", 'dincome', + f"{_bvn9:,.0f}" if _bvn9 else '') + if _bvn9: + ttk.Label(frm, text="⚠️ ΔДоходов — подставлена балансовая стоимость объекта КИИ " + f"({_bvn9:,.0f} руб.); можно поправить.", + foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) + else: + ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено: введите вручную или заполните " + "балансовую стоимость объекта КИИ во вкладке «Объект».", + foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) + e_budget = _row("Прогнозируемый годовой доход федерального бюджета, усреднённый за планируемый трёхлетний период — Бусредн (руб.):", 'budget', f'{DEFAULT_BUDGET_AVG:.0f}') - ttk.Label(frm, text="⚠️ Бусредн — средний доход федерального бюджета за 3 года (данные Минфина, " - "подставлено значение по умолчанию; при необходимости уточните).", + ttk.Label(frm, text="⚠️ Бусредн — прогнозируемый годовой доход федерального бюджета, " + "усреднённый за планируемый трёхлетний период (данные Минфина; " + "подставлено значение по умолчанию, при необходимости уточните).", foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) ttk.Label(frm, text="Прочие составляющие ΔБ (если применимы, иначе 0):", font=('', 9, 'bold')).pack(anchor=tk.W, pady=(8, 0)) @@ -2283,11 +2298,23 @@ class DokoGenApp: _incs_filled = [x for x in _incs if x is not None] _d_avg = (sum(_incs_filled) / len(_incs_filled)) if _incs_filled else None _d_loss = _num(getattr(self.model, 'income_loss', '') or '') + _bv8 = getattr(isys, 'balance_value', '') or '' + _bvn8 = _num(_bv8) + _dincome_def = _d_loss or _bvn8 e_dincome = _row("Снижение уровня дохода от нарушения критических процессов (руб.):", - 'dincome', f"{_d_loss:,.0f}" if _d_loss else '') - ttk.Label(frm, text="⚠️ ΔДоходов — поле «Снижение уровня дохода (показатель 8)» из вкладки «Организация» " - "(подставлено автоматически, можно поправить).", - foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) + 'dincome', f"{_dincome_def:,.0f}" if _dincome_def else '') + if _d_loss: + ttk.Label(frm, text="⚠️ ΔДоходов — подставлено из вкладки «Организация» " + f"({_d_loss:,.0f} руб.); можно поправить.", + foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) + elif _bvn8: + ttk.Label(frm, text="⚠️ ΔДоходов — подставлена балансовая стоимость объекта КИИ " + f"({_bvn8:,.0f} руб.); можно поправить.", + foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) + else: + ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено: введите вручную или заполните " + "«Снижение уровня дохода» во вкладке «Организация».", + foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) e_budget = _row("Доходы субъекта КИИ, усреднённые за 5 лет (руб.):", 'budget', f"{_d_avg:,.0f}" if _d_avg else '') _n_years = len(_incs_filled) @@ -2350,7 +2377,7 @@ class DokoGenApp: db = dnp + ndpi + div + vso + ns lines.append(f"ΔНДПИ = {ndpi:,.0f} руб., ΔДИВ = {div:,.0f} руб., ΔВСО = {vso:,.0f} руб., ΔНС = {ns:,.0f} руб.") lines.append(f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.") - lines.append(f"Бусредн (доход федерального бюджета за 3 года): {b:,.0f} руб.") + lines.append(f"Бусредн (прогноз годового дохода федбюджета, усреднённый за планируемый трёхлетний период): {b:,.0f} руб.") val = db / b * 100 lines.append(f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {b:,.0f} × 100 = {val:.6f} %") if val > 0.001: @@ -2678,20 +2705,24 @@ class DokoGenApp: profit = _num2(getattr(self.model, 'profit', '') or '') tax = _num2(getattr(self.model, 'profit_tax', '') or '') if profit and tax: - # Подсказка: балансовая стоимость объекта как ориентир для ΔДоходов - _hint = '' + # ΔДоходов: если есть балансовая стоимость объекта КИИ — + # подставляем её автоматически (наихудший сценарий = объект + # выходит из строя, его надо восстанавливать/покупать заново) _bv = getattr(isys, 'balance_value', '') or '' _bvn = _num2(_bv) if _bvn: - _hint = f"\n(балансовая стоимость объекта: {_bvn:,.0f} руб. — можно использовать как ориентир)" - dincome = simpledialog.askfloat( - "Показатель 9 — ущерб бюджетам", - "Снижение уровня доходов субъекта КИИ ΔДоходов (руб.):\n" - "(наихудший сценарий компьютерной атаки на объект)" + _hint, - parent=dialog, minvalue=0) - if dincome is None: - continue - budget = DEFAULT_BUDGET_AVG # Бусредн (доход федерального бюджета за 3 года) + dincome = _bvn + _dincome_note = (f"ΔДоходов = балансовая стоимость объекта КИИ " + f"({dincome:,.0f} руб.) — подставлено автоматически") + else: + dincome = self._ask_amount( + dialog, "Показатель 9 — ущерб бюджетам", + "Снижение уровня доходов субъекта КИИ ΔДоходов (руб.):\n" + "(наихудший сценарий компьютерной атаки на объект)") + if dincome is None: + continue + _dincome_note = f"ΔДоходов (введено оператором) = {dincome:,.0f} руб." + budget = DEFAULT_BUDGET_AVG # Бусредн (прогноз годового дохода федбюджета, усреднённый за 3 года) knp = tax / profit dnp = dincome * knp if dincome > profit: @@ -2718,11 +2749,12 @@ class DokoGenApp: if target: var.set(target) calc_txt = ( + f"{_dincome_note}\n" f"Коэффициент КНП = налог/прибыль = {tax:,.0f} / {profit:,.0f} = {knp:.4f}\n" f"ΔНП = ΔДоходов × КНП = {dincome:,.0f} × {knp:.4f} = {dnp:,.1f} руб.\n" f"ΔНДПИ = {ndpi:,.0f}, ΔДИВ = {div:,.0f}, ΔВСО = {vso:,.0f}, ΔНС = {ns:,.0f} руб.\n" f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.\n" - f"Бусредн (доход федерального бюджета за 3 года) = {budget:,.0f} руб.\n" + f"Бусредн (прогноз годового дохода федбюджета, усреднённый за 3 года) = {budget:,.0f} руб.\n" f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {budget:,.0f} × 100 = {val:.6f} %\n" f"Категория значимости: {cat or '— (не присваивается)'}" ) @@ -2945,6 +2977,61 @@ class DokoGenApp: self.log(f"🤖 ИИ подобрал угроз: {len(threats)}") self._verify_log(f"🤖 ИИ: {len(threats)} угроз") + def _ask_amount(self, parent, title, prompt, initial=''): + """Свой диалог ввода суммы: принимает «121 118 270 руб», «1 000» и т.п. + + simpledialog.askfloat НЕ умеет парсить такие строки (ошибка + «not a floating-point value please try again») — поэтому свой диалог. + Возвращает float или None (отмена). + """ + def _parse(s): + s = str(s or '').strip() + if not s: + return None + s = s.replace('\u00a0', ' ').replace(' ', '')\ + .replace('руб', '').replace('₽', '').replace('р.', '').replace(',', '.') + try: + return float(s) + except Exception: + return None + + result = {'val': None} + dlg = tk.Toplevel(parent) + dlg.title(title) + dlg.geometry("560x200") + dlg.transient(parent) + dlg.grab_set() + ttk.Label(dlg, text=prompt, wraplength=520, justify=tk.LEFT).pack( + padx=12, pady=(12, 6), anchor=tk.W) + e = ttk.Entry(dlg, width=42) + e.insert(0, str(initial or '')) + e.pack(padx=12, pady=4, fill=tk.X) + err = ttk.Label(dlg, text="", foreground='red') + err.pack(anchor=tk.W, padx=12) + + def _ok(): + v = _parse(e.get()) + if v is None: + err.config(text="Введите число, например: 121 118 270") + return + result['val'] = v + dlg.grab_release() + dlg.destroy() + + def _cancel(): + dlg.grab_release() + dlg.destroy() + + btns = ttk.Frame(dlg) + btns.pack(pady=8) + ttk.Button(btns, text="ОК", command=_ok).pack(side=tk.LEFT, padx=4) + ttk.Button(btns, text="Отмена", command=_cancel).pack(side=tk.LEFT, padx=4) + e.bind('', lambda _ev: _ok()) + dlg.lift() + dlg.focus_force() + parent.wait_window(dlg) + return result['val'] + def _compare_threats(self): """Сравнить угрозы, подобранные программой и ИИ.""" if self._current_is_idx is None: @@ -2979,27 +3066,38 @@ class DokoGenApp: 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("960x600") + 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=18) + 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) 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=18) + 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) for t in current: lb_c.insert(tk.END, t) @@ -3020,6 +3118,99 @@ class DokoGenApp: foreground='gray') info.pack(anchor=tk.W, pady=(4, 0)) + def _check_threats_ai_click(self): + """🤖 Проверить текущий список угроз через ИИ (не подбирать заново). + + Программа/оператор уже подобрали угрозы — ИИ проверяет, что лишнее, + чего не хватает, и даёт комментарий. + """ + if self._current_is_idx is None: + messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева") + return + settings = load_api_settings() + if not settings.get('ai_api_key'): + messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»") + return + current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())] + if not current: + messagebox.showinfo("Угрозы", "Сначала подберите угрозы (программой или ИИ).") + return + self._collect_is() + isys = self.model.information_systems[self._current_is_idx] + from .threat_rules import _has_internet + has_internet = _has_internet(isys) + data = { + 'name': isys.name or '', + 'description': isys.description or '', + 'object_type': isys.object_type or '', + 'sphere': isys.sphere or '', + 'architecture': isys.architecture or '', + 'net_category': isys.net_category or '', + 'attacker_category': isys.attacker_category or '', + 'has_internet': has_internet, + } + self.log(f"🤖 Запрос к ИИ: проверка угроз для «{isys.name}» (в списке: {len(current)})") + self._verify_log("Проверка угроз через ИИ…") + + def _run(): + try: + from .verify import check_threats_ai + res, err = check_threats_ai( + data, current, settings['ai_api_key'], settings.get('ai_model'), + settings.get('ai_base_url')) + self.root.after(0, lambda: self._check_threats_ai_done(res, err, isys.name)) + except Exception as e: + self.root.after(0, lambda: self._check_threats_ai_done(None, str(e), isys.name)) + + threading.Thread(target=_run, daemon=True).start() + + def _check_threats_ai_done(self, res, err, obj_name): + """Показ результата проверки угроз ИИ.""" + if err: + self.log(f"❌ Проверка угроз: {err}") + self._verify_log(f"❌ {err}") + return + if not isinstance(res, dict): + self.log("❌ ИИ не вернул результат проверки") + return + extra = res.get('лишние') or [] + missing = res.get('недостающие') or [] + comment = res.get('комментарий') or '' + if isinstance(extra, str): + extra = [extra] + if isinstance(missing, str): + missing = [missing] + lines = [f"🤖 Проверка угроз для «{obj_name}»:"] + lines.append(f"Всего в списке: {self.is_threats_listbox.size()}") + if extra: + lines.append(f"\n❌ Возможно ЛИШНИЕ ({len(extra)}):") + for t in extra: + lines.append(f" • {t}") + else: + lines.append("\n❌ Лишних не найдено") + if missing: + lines.append(f"\n➕ Возможно НЕ ХВАТАЕТ ({len(missing)}):") + for t in missing: + lines.append(f" • {t}") + else: + lines.append("\n➕ Недостающих не найдено") + if comment: + lines.append(f"\n💬 Комментарий: {comment}") + lines.append("\nРекомендации ИИ — оператор принимает решение.") + + dlg = tk.Toplevel(self.root) + dlg.title(f"Проверка угроз ИИ — {obj_name}") + dlg.geometry("820x560") + 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)) + self.log(f"🤖 Проверка угроз ИИ: лишних {len(extra)}, недостающих {len(missing)}") + self._verify_log('\n'.join(lines)) + def _check_security_ai(self): """Проверка вкладки «Безопасность» через ИИ (DeepSeek/OpenAI-совместимый API).""" if self._current_is_idx is None: diff --git a/dokogen/verify.py b/dokogen/verify.py index 2b28c81..94e110d 100644 --- a/dokogen/verify.py +++ b/dokogen/verify.py @@ -340,8 +340,15 @@ def check_company_dadata(inn, token): # ОКВЭД: основной (okved) и дополнительные (okveds — список словарей с 'code') okved = d.get('okved', '') or '' okved_extra = [] - for ok in (d.get('okveds') or []): - code = (ok or {}).get('code') or '' + raw_okveds = d.get('okveds') or [] + if isinstance(raw_okveds, str): + # DaData иногда отдаёт доп. ОКВЭД одной строкой через запятую + raw_okveds = [x.strip() for x in raw_okveds.split(',') if x.strip()] + for ok in raw_okveds: + if isinstance(ok, dict): + code = (ok.get('code') or '').strip() + else: + code = str(ok or '').strip() if code: okved_extra.append(code) @@ -567,6 +574,41 @@ def suggest_threats_ai(security_data, api_key, model='deepseek-chat', base_url=N return threats_out, None +def check_threats_ai(security_data, current_threats, api_key, model='deepseek-chat', base_url=None): + """ИИ проверяет уже подобранный список угроз БИ для объекта КИИ. + + В отличие от suggest_threats_ai (подбор заново), эта функция проверяет + текущий список: какие угрозы лишние, каких не хватает, и даёт комментарий. + + Возвращает (dict, ошибка): + {"лишние": [...], "недостающие": [...], "комментарий": "..."} + """ + if not api_key: + return None, "Не задан API-ключ ИИ (Файл → Настройки → API)" + if not current_threats: + return None, "Список угроз пуст — сначала подберите угрозы (программой или ИИ)." + + system = ( + "Ты — эксперт ФСТЭК по безопасности критической информационной инфраструктуры " + "(187-ФЗ, приказ ФСТЭК № 236). Проверь, корректно ли подобран перечень угроз " + "безопасности информации для объекта КИИ." + ) + user = ( + "Характеристики объекта КИИ:\n" + f"{json.dumps(security_data, ensure_ascii=False)}\n\n" + "Текущий перечень угроз (подобран программой или оператором):\n" + + "\n".join(f"{i+1}. {t}" for i, t in enumerate(current_threats)) + + "\n\nВерни ТОЛЬКО JSON: " + '{"лишние": ["угрозы, которые НЕ актуальны для этого объекта"], ' + '"недостающие": ["актуальные угрозы, которых в списке не хватает"], ' + '"комментарий": "краткий вывод по перечню"}' + ) + js, err = _ai_json(api_key, model, base_url, system, user) + if err: + return None, err + return js, None + + def suggest_object_params_ai(object_data, api_key, model='deepseek-chat', base_url=None, typical_objects=None, spheres=None, object_types=None, architectures=None):