fix: доп. ОКВЭД из DaData (устойчивый парсинг + лог), сравнение угроз (шире окно, сортировка по УБИ), кнопка «Проверить через ИИ» (check_threats_ai), честные подсказки п.8/п.9, автозаполнение ΔДоходов из балансовой стоимости КИИ, диалог _ask_amount вместо askfloat (формат «121 118 270 руб»), формулировка Бусредн по документу ФСТЭК (трёхлетний период)

This commit is contained in:
LocoAgent
2026-08-05 10:41:31 +04:00
parent 5af23ae525
commit bf75d67f5e
2 changed files with 264 additions and 31 deletions
+220 -29
View File
@@ -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('<Return>', 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: