fix: paragraph-level docx replace, LLM-NER, НУБЕС whitelist, COMPANY_PATTERN limit — v0.6/v1.0.189
Deploy contracts-flask / validate (push) Successful in 0s

This commit is contained in:
2026-06-29 16:32:35 +04:00
parent a2a094969b
commit 7c40c178fe
5 changed files with 65 additions and 23 deletions
+30 -10
View File
@@ -6,7 +6,7 @@ DrHider — обфускация документов (двухпроходна
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение. Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
""" """
DRHIDER_VERSION = "0.5" DRHIDER_VERSION = "0.6"
import io import io
import csv import csv
import re import re
@@ -40,7 +40,13 @@ ENTITY_PATTERNS: Dict[str, str] = {
# Фирмы — обнаружение по шаблону # Фирмы — обнаружение по шаблону
COMPANY_PATTERN = re.compile( COMPANY_PATTERN = re.compile(
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|[А-ЯA-Z][\w\s\-\.]+)', r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|\u201c[^\u201d]+\u201d|[А-ЯA-Z][\w\-\.]{2,40})',
re.IGNORECASE
)
# ФИО — Фамилия + инициалы
PERSON_PATTERN = re.compile(
r'\b[А-ЯA-Z][а-яa-z]+\s+[А-ЯA-Z]\.[А-ЯA-Z]\.',
re.IGNORECASE re.IGNORECASE
) )
@@ -335,12 +341,20 @@ class TwoPassObfuscator:
generator = ENTITY_GENERATORS.get(entity_type, lambda x: "XXX") generator = ENTITY_GENERATORS.get(entity_type, lambda x: "XXX")
self._mapping[original] = generator(original) self._mapping[original] = generator(original)
# Компании # Компании (кроме НУБЕС — это Исполнитель)
for match in COMPANY_PATTERN.finditer(text): for match in COMPANY_PATTERN.finditer(text):
original = match.group(0).strip() original = match.group(0).strip()
if original and original not in self._mapping: if original and original not in self._mapping:
if re.search(r'НУБЕС|NUBES', original, re.IGNORECASE):
continue # Исполнитель — не заменяем
self._mapping[original] = generate_company(original) self._mapping[original] = generate_company(original)
# ФИО (Фамилия И.О.)
for match in PERSON_PATTERN.finditer(text):
original = match.group(0).strip()
if original and original not in self._mapping:
self._mapping[original] = generate_person(original)
def _scan_llm_ner(self, all_texts: Dict[str, str]): def _scan_llm_ner(self, all_texts: Dict[str, str]):
"""LLM NER для обнаружения имён и адресов во всех файлах.""" """LLM NER для обнаружения имён и адресов во всех файлах."""
combined = "\n\n---FILE---\n\n".join( combined = "\n\n---FILE---\n\n".join(
@@ -384,19 +398,25 @@ class TwoPassObfuscator:
# --- Проход 2: замена --- # --- Проход 2: замена ---
def _replace_in_docx(self, doc) -> bytes: def _replace_in_docx(self, doc) -> bytes:
"""Заменить сущности в docx-документе (in-place).""" """Заменить сущности в docx-документе. Склеиваем runs → заменяем → пишем в первый run, остальные очищаем."""
# Замена в параграфах
for para in doc.paragraphs: for para in doc.paragraphs:
for run in para.runs: full_text = "".join(run.text for run in para.runs)
run.text = self._apply_replacements(run.text) replaced = self._apply_replacements(full_text)
if replaced != full_text and para.runs:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
# Замена в таблицах
for table in doc.tables: for table in doc.tables:
for row in table.rows: for row in table.rows:
for cell in row.cells: for cell in row.cells:
for para in cell.paragraphs: for para in cell.paragraphs:
for run in para.runs: full_text = "".join(run.text for run in para.runs)
run.text = self._apply_replacements(run.text) replaced = self._apply_replacements(full_text)
if replaced != full_text and para.runs:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
buf = io.BytesIO() buf = io.BytesIO()
doc.save(buf) doc.save(buf)
+3 -1
View File
@@ -133,7 +133,9 @@ def api_drhider():
def _process(): def _process():
try: try:
zip_data, _csv = obfuscate_files(files) from services.llm_client import HttpxLLMClient
llm = HttpxLLMClient(LLM_URL, LLM_KEY, LLM_MODEL) if LLM_KEY else None
zip_data, _csv = obfuscate_files(files, llm_client=llm)
with _drhider_lock: with _drhider_lock:
_drhider_jobs[job_id] = {"done": True, "data": zip_data, "error": None} _drhider_jobs[job_id] = {"done": True, "data": zip_data, "error": None}
except Exception as e: except Exception as e:
+30 -10
View File
@@ -6,7 +6,7 @@ DrHider — обфускация документов (двухпроходна
Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение. Согласованность: одна и та же сущность во всех файлах → одно и то же фиктивное значение.
""" """
DRHIDER_VERSION = "0.5" DRHIDER_VERSION = "0.6"
import io import io
import csv import csv
import re import re
@@ -40,7 +40,13 @@ ENTITY_PATTERNS: Dict[str, str] = {
# Фирмы — обнаружение по шаблону # Фирмы — обнаружение по шаблону
COMPANY_PATTERN = re.compile( COMPANY_PATTERN = re.compile(
r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|[А-ЯA-Z][\w\s\-\.]+)', r'(?:ООО|ЗАО|ОАО|АО|ПАО|ИП|ТОО)\s+(?:«[^»]+»|"[^"]+"|\u201c[^\u201d]+\u201d|[А-ЯA-Z][\w\-\.]{2,40})',
re.IGNORECASE
)
# ФИО — Фамилия + инициалы
PERSON_PATTERN = re.compile(
r'\b[А-ЯA-Z][а-яa-z]+\s+[А-ЯA-Z]\.[А-ЯA-Z]\.',
re.IGNORECASE re.IGNORECASE
) )
@@ -335,12 +341,20 @@ class TwoPassObfuscator:
generator = ENTITY_GENERATORS.get(entity_type, lambda x: "XXX") generator = ENTITY_GENERATORS.get(entity_type, lambda x: "XXX")
self._mapping[original] = generator(original) self._mapping[original] = generator(original)
# Компании # Компании (кроме НУБЕС — это Исполнитель)
for match in COMPANY_PATTERN.finditer(text): for match in COMPANY_PATTERN.finditer(text):
original = match.group(0).strip() original = match.group(0).strip()
if original and original not in self._mapping: if original and original not in self._mapping:
if re.search(r'НУБЕС|NUBES', original, re.IGNORECASE):
continue # Исполнитель — не заменяем
self._mapping[original] = generate_company(original) self._mapping[original] = generate_company(original)
# ФИО (Фамилия И.О.)
for match in PERSON_PATTERN.finditer(text):
original = match.group(0).strip()
if original and original not in self._mapping:
self._mapping[original] = generate_person(original)
def _scan_llm_ner(self, all_texts: Dict[str, str]): def _scan_llm_ner(self, all_texts: Dict[str, str]):
"""LLM NER для обнаружения имён и адресов во всех файлах.""" """LLM NER для обнаружения имён и адресов во всех файлах."""
combined = "\n\n---FILE---\n\n".join( combined = "\n\n---FILE---\n\n".join(
@@ -384,19 +398,25 @@ class TwoPassObfuscator:
# --- Проход 2: замена --- # --- Проход 2: замена ---
def _replace_in_docx(self, doc) -> bytes: def _replace_in_docx(self, doc) -> bytes:
"""Заменить сущности в docx-документе (in-place).""" """Заменить сущности в docx-документе. Склеиваем runs → заменяем → пишем в первый run, остальные очищаем."""
# Замена в параграфах
for para in doc.paragraphs: for para in doc.paragraphs:
for run in para.runs: full_text = "".join(run.text for run in para.runs)
run.text = self._apply_replacements(run.text) replaced = self._apply_replacements(full_text)
if replaced != full_text and para.runs:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
# Замена в таблицах
for table in doc.tables: for table in doc.tables:
for row in table.rows: for row in table.rows:
for cell in row.cells: for cell in row.cells:
for para in cell.paragraphs: for para in cell.paragraphs:
for run in para.runs: full_text = "".join(run.text for run in para.runs)
run.text = self._apply_replacements(run.text) replaced = self._apply_replacements(full_text)
if replaced != full_text and para.runs:
para.runs[0].text = replaced
for run in para.runs[1:]:
run.text = ""
buf = io.BytesIO() buf = io.BytesIO()
doc.save(buf) doc.save(buf)
+1 -1
View File
@@ -64,7 +64,7 @@
<a href="/">Сверка договоров</a> <a href="/">Сверка договоров</a>
<span class="sep">|</span> <span class="sep">|</span>
<strong>DrHider</strong> <strong>DrHider</strong>
<span style="font-size:11px;color:var(--muted);">v0.5</span> <span style="font-size:11px;color:var(--muted);">v0.6</span>
</header> </header>
<main> <main>
+1 -1
View File
@@ -73,7 +73,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="/static/logo.svg" alt="Nubes"> <img src="/static/logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.188-flask</span></span> <span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.189-flask</span></span>
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);"> <div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
<span id="stepUpload">○ Загрузка</span><span></span> <span id="stepUpload">○ Загрузка</span><span></span>
<span id="stepClassify">○ Классификация</span><span></span> <span id="stepClassify">○ Классификация</span><span></span>