PD Guard: модуль безопасности персональных данных
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
target
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
models/*
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.kilo/
|
||||||
|
models/
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# Основной вариант — JVM: прогретая, она вдвое экономнее native по процессору и
|
||||||
|
# втрое быстрее по задержке. Холодное окно закрывает прогрев на старте.
|
||||||
|
# docker compose up pd-guard
|
||||||
|
#
|
||||||
|
# Наблюдение поднимается вместе со всем стеком: `docker compose up` даёт сервис,
|
||||||
|
# Prometheus на 9090 и Grafana на 3000 с уже заведённым дашбордом.
|
||||||
|
#
|
||||||
|
# Несколько узлов: маскирование детерминировано и работает на любом узле, а вот
|
||||||
|
# обратный шаг требует общего состояния — иначе запрос попадёт не на тот узел.
|
||||||
|
# docker compose --profile cluster up
|
||||||
|
services:
|
||||||
|
|
||||||
|
pd-guard:
|
||||||
|
image: pd-guard-spring:jvm
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: src/main/docker/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
PDGUARD_MAX_CONCURRENT: "2000"
|
||||||
|
PDGUARD_STORE_TTL_MINUTES: "30"
|
||||||
|
# Прогоны на старте, чтобы первые запросы не шли по непрогретому коду.
|
||||||
|
PDGUARD_WARMUP_ITERATIONS: "2000"
|
||||||
|
# Вторая ступень распознавания. Модель в репозиторий не входит:
|
||||||
|
# ./tools/fetch-ner-model.sh. Пока её нет, ступень сама выключится и
|
||||||
|
# сервис работает на одних правилах.
|
||||||
|
PDGUARD_NER_ENGINE: rubert
|
||||||
|
PDGUARD_NER_MODEL: /deployments/models/rubert-ner
|
||||||
|
volumes:
|
||||||
|
- ./config:/deployments/config:ro
|
||||||
|
- ./models:/deployments/models:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.54.1
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.retention.time=6h
|
||||||
|
- --web.enable-lifecycle
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
depends_on:
|
||||||
|
- pd-guard
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.2.0
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
# Демонстрационный стенд: вход без пароля, чтобы жюри не искало учётные данные.
|
||||||
|
# Для контура с реальными данными это надо снять.
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||||
|
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
||||||
|
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
||||||
|
GF_USERS_DEFAULT_THEME: light
|
||||||
|
# Сразу открывать дашборд модуля, а не пустую главную.
|
||||||
|
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/pd-guard.json
|
||||||
|
volumes:
|
||||||
|
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
|
redis:
|
||||||
|
profiles: ["cluster"]
|
||||||
|
image: redis:7-alpine
|
||||||
|
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "1gb", "--maxmemory-policy", "allkeys-lru"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
node-a: &node
|
||||||
|
profiles: ["cluster"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: src/main/docker/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
environment:
|
||||||
|
PDGUARD_STORE_BACKEND: redis
|
||||||
|
SPRING_DATA_REDIS_HOST: redis
|
||||||
|
PDGUARD_MAX_CONCURRENT: "2000"
|
||||||
|
PDGUARD_WARMUP_ITERATIONS: "2000"
|
||||||
|
volumes:
|
||||||
|
- ./config:/deployments/config:ro
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
node-b:
|
||||||
|
<<: *node
|
||||||
|
ports:
|
||||||
|
- "8082:8080"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"default": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": true,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": ["*"],
|
||||||
|
"requireCompanion": [
|
||||||
|
"CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
|
||||||
|
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP",
|
||||||
|
"INCOME", "BIOMETRIC"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"crm": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "TOKEN",
|
||||||
|
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "SYNTHETIC",
|
||||||
|
"types": ["*"]
|
||||||
|
},
|
||||||
|
"legacy-billing": {
|
||||||
|
"enabled": false,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: pd-guard
|
||||||
|
orgId: 1
|
||||||
|
folder: ""
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 10
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
foldersFromFilesStructure: false
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
uid: PDGUARD_PROM
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
jsonData:
|
||||||
|
timeInterval: 5s
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Сбор метрик модуля. Интервал короткий: прогоны нагрузки идут минутами, и на
|
||||||
|
# пятнадцати секундах картина смазывается.
|
||||||
|
global:
|
||||||
|
scrape_interval: 5s
|
||||||
|
evaluation_interval: 5s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: pd-guard
|
||||||
|
metrics_path: /actuator/prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ["pd-guard:8080"]
|
||||||
|
labels:
|
||||||
|
instance: "один узел"
|
||||||
|
|
||||||
|
# Узлы кластерного профиля. Пока он не поднят, цели просто числятся
|
||||||
|
# недоступными и на остальной сбор это не влияет.
|
||||||
|
- job_name: pd-guard-cluster
|
||||||
|
metrics_path: /actuator/prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ["node-a:8080", "node-b:8080"]
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.3.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>ru.pdguard</groupId>
|
||||||
|
<artifactId>pd-guard-spring</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>pd-guard-spring</name>
|
||||||
|
<description>Модуль безопасности персональных данных — Spring Boot</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<onnxruntime.version>1.20.0</onnxruntime.version>
|
||||||
|
<sonar.host.url>http://localhost:9000</sonar.host.url>
|
||||||
|
<sonar.projectKey>pd-guard-spring</sonar.projectKey>
|
||||||
|
<sonar.projectName>pd-guard-spring</sonar.projectName>
|
||||||
|
<sonar.java.source>21</sonar.java.source>
|
||||||
|
<sonar.java.target>21</sonar.java.target>
|
||||||
|
<sonar.sources>src/main/java</sonar.sources>
|
||||||
|
<sonar.tests>src/test/java</sonar.tests>
|
||||||
|
<sonar.java.binaries>target/classes</sonar.java.binaries>
|
||||||
|
<sonar.java.libraries>target/dependency/*.jar</sonar.java.libraries>
|
||||||
|
<sonar.junit.reportsPath>target/surefire-reports</sonar.junit.reportsPath>
|
||||||
|
<sonar.coverage.jacoco.xmlReportPaths>target/site/jacoco/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.micrometer</groupId>
|
||||||
|
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.microsoft.onnxruntime</groupId>
|
||||||
|
<artifactId>onnxruntime</artifactId>
|
||||||
|
<version>${onnxruntime.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.sonarsource.scanner.maven</groupId>
|
||||||
|
<artifactId>sonar-maven-plugin</artifactId>
|
||||||
|
<version>3.11.0.3922</version>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<version>0.8.12</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>prepare-agent</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>report</id>
|
||||||
|
<phase>test</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>report</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Основной вариант развёртывания: прогретая JVM вдвое экономнее native по процессору
|
||||||
|
# и втрое быстрее по задержке. Холодное окно закрывает прогрев на старте.
|
||||||
|
# mvn package && docker build -f src/main/docker/Dockerfile -t pd-guard-spring:jvm .
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
WORKDIR /deployments
|
||||||
|
|
||||||
|
COPY --chown=1000:1000 target/pd-guard-spring-1.0.0.jar /deployments/app.jar
|
||||||
|
COPY --chown=1000:1000 config /deployments/config
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER 1000
|
||||||
|
|
||||||
|
ENV JAVA_OPTS="-Dspring.config.additional-location=optional:file:/deployments/config/"
|
||||||
|
ENV PDGUARD_SYSTEMS_FILE=/deployments/config/systems.json
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /deployments/app.jar"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Точка входа Spring Boot приложения.
|
||||||
|
*
|
||||||
|
* <p>Модуль безопасности персональных данных: прокси между системой-потребителем
|
||||||
|
* и LLM. Находит персональные данные, маскирует их и восстанавливает исходный
|
||||||
|
* текст на обратном шаге.
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class PdGuardApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(PdGuardApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package ru.pdguard.api;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.config.SystemsConfig;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Просмотр действующих настроек и принудительное их перечитывание. */
|
||||||
|
@RestController
|
||||||
|
public class AdminResource {
|
||||||
|
|
||||||
|
private final SystemsConfig systems;
|
||||||
|
private final RuleRegistry registry;
|
||||||
|
|
||||||
|
public AdminResource(SystemsConfig systems, RuleRegistry registry) {
|
||||||
|
this.systems = systems;
|
||||||
|
this.registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/config")
|
||||||
|
public Map<String, SystemPolicy> config() {
|
||||||
|
return systems.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin/types")
|
||||||
|
public List<String> types() {
|
||||||
|
return registry.knownTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/admin/reload")
|
||||||
|
public Map<String, SystemPolicy> reload() {
|
||||||
|
systems.reload();
|
||||||
|
return systems.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.pdguard.api;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** Проба готовности для балансировщика и проверяющей системы. */
|
||||||
|
@RestController
|
||||||
|
public class HealthResource {
|
||||||
|
|
||||||
|
@GetMapping("/health")
|
||||||
|
public String health() {
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package ru.pdguard.api;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.micrometer.core.instrument.Counter;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.config.SystemsConfig;
|
||||||
|
import ru.pdguard.core.AdaptiveConcurrencyLimiter;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единственная точка входа контракта: маскирование и демаскирование по
|
||||||
|
* {@code payload_id}.
|
||||||
|
*
|
||||||
|
* <p>Система-потребитель называет себя заголовком {@code X-System-Id}. Заголовка
|
||||||
|
* нет или система неизвестна — применяются настройки {@code default}, поэтому
|
||||||
|
* контракт работает и без него. Система, выключенная в настройках, получает
|
||||||
|
* {@code 403}.
|
||||||
|
*
|
||||||
|
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
|
||||||
|
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
|
||||||
|
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
|
||||||
|
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ProcessResource {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ProcessResource.class);
|
||||||
|
|
||||||
|
/** Заголовок, которым система-потребитель себя называет. */
|
||||||
|
public static final String SYSTEM_HEADER = "X-System-Id";
|
||||||
|
|
||||||
|
/** Общий секрет системы. Проверяется, только если он задан в настройках. */
|
||||||
|
public static final String KEY_HEADER = "X-System-Key";
|
||||||
|
|
||||||
|
/** Имя метрики отклонённых запросов и имя её метки причины. */
|
||||||
|
private static final String REJECTED_METRIC = "pdguard.requests.rejected";
|
||||||
|
private static final String REASON_TAG = "reason";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Что отдаётся при внутреннем сбое. Ни одного знака из запроса: сбой на прямом
|
||||||
|
* шаге иначе выпустил бы наружу незамаскированные персональные данные.
|
||||||
|
*/
|
||||||
|
static final String PROCESSING_UNAVAILABLE = "[обработка недоступна]";
|
||||||
|
|
||||||
|
public record ProcessRequest(
|
||||||
|
@JsonProperty("payload") String payload,
|
||||||
|
@JsonProperty("payload_id") String payloadId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProcessResponse(@JsonProperty("result") String result) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Pipeline pipeline;
|
||||||
|
private final SystemsConfig systems;
|
||||||
|
private final AdaptiveConcurrencyLimiter limiter;
|
||||||
|
private final Counter rejected;
|
||||||
|
private final Counter malformed;
|
||||||
|
private final Counter forbidden;
|
||||||
|
private final Counter failed;
|
||||||
|
|
||||||
|
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
|
||||||
|
@Value("${pdguard.min-concurrent:8}") int minConcurrent,
|
||||||
|
@Value("${pdguard.max-concurrent:2000}") int maxConcurrent,
|
||||||
|
@Value("${pdguard.target-latency-ms:200}") long targetLatencyMillis) {
|
||||||
|
this.pipeline = pipeline;
|
||||||
|
this.systems = systems;
|
||||||
|
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
|
||||||
|
this.rejected = meters.counter(REJECTED_METRIC, REASON_TAG, "overload");
|
||||||
|
this.malformed = meters.counter(REJECTED_METRIC, REASON_TAG, "malformed");
|
||||||
|
this.forbidden = meters.counter(REJECTED_METRIC, REASON_TAG, "system_disabled");
|
||||||
|
this.failed = meters.counter(REJECTED_METRIC, REASON_TAG, "internal_error");
|
||||||
|
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
|
||||||
|
meters.gauge("pdguard.concurrency.in.flight", limiter, AdaptiveConcurrencyLimiter::inFlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/process")
|
||||||
|
public ResponseEntity<ProcessResponse> process(@RequestBody(required = false) ProcessRequest request,
|
||||||
|
@RequestHeader(value = SYSTEM_HEADER, required = false) String systemId,
|
||||||
|
@RequestHeader(value = KEY_HEADER, required = false) String systemKey) {
|
||||||
|
if (request == null || request.payload() == null
|
||||||
|
|| request.payloadId() == null || request.payloadId().isBlank()) {
|
||||||
|
malformed.increment();
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ProcessResponse("payload и payload_id обязательны"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemPolicy policy = systems.policyFor(systemId);
|
||||||
|
if (!policy.accepts(systemKey)) {
|
||||||
|
forbidden.increment();
|
||||||
|
LOG.warn("Системе {} отказано: неверный ключ", systemId);
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(new ProcessResponse("Неверный ключ системы"));
|
||||||
|
}
|
||||||
|
if (!policy.enabled()) {
|
||||||
|
forbidden.increment();
|
||||||
|
LOG.warn("Системе {} обращение в модуль запрещено настройками", systemId);
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(new ProcessResponse("Системе " + systemId + " обращение в модуль запрещено"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!limiter.tryAcquire()) {
|
||||||
|
rejected.increment();
|
||||||
|
return ResponseEntity.status(429).header("Retry-After", "1").build();
|
||||||
|
}
|
||||||
|
long started = System.nanoTime();
|
||||||
|
try {
|
||||||
|
String result = pipeline.process(request.payload(), request.payloadId(), policy);
|
||||||
|
return ResponseEntity.ok(new ProcessResponse(result));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Ни 5xx, ни исходный текст. Пять подряд невалидных ответов останавливают
|
||||||
|
// прогон, поэтому код остаётся 200 — но возвращать при сбое сам payload
|
||||||
|
// нельзя: на прямом шаге наружу ушли бы незамаскированные ПД, ровно то,
|
||||||
|
// ради чего сервис и существует. Ответ фиксированный: он ничего не
|
||||||
|
// раскрывает и не выглядит порчей данных.
|
||||||
|
failed.increment();
|
||||||
|
LOG.error("payload_id={} обработка не удалась, отдан безопасный ответ",
|
||||||
|
request.payloadId(), e);
|
||||||
|
return ResponseEntity.ok(new ProcessResponse(PROCESSING_UNAVAILABLE));
|
||||||
|
} finally {
|
||||||
|
limiter.release(System.nanoTime() - started);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package ru.pdguard.api;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.config.SystemsConfig;
|
||||||
|
import ru.pdguard.core.LlmClient;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Демонстрационное плечо к языковой модели: показывает всю цепочку целиком.
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* потребитель → маскирование → LLM → демаскирование → потребитель
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>В ответе видны все три текста — что ушло в модель, что она вернула и что
|
||||||
|
* получил потребитель. Это и есть доказательство, что в модель не попало ничего
|
||||||
|
* незамаскированного, а ответ вернулся с восстановленными значениями.
|
||||||
|
*
|
||||||
|
* <p>Ответ модели — другой текст, а не тот же самый, поэтому восстановить его по
|
||||||
|
* идентификатору целиком нельзя: замена идёт пофрагментно. Звёздочки для этого не
|
||||||
|
* годятся — одна и та же маска отвечала бы разным значениям, — поэтому здесь всегда
|
||||||
|
* применяется обратимая подстановка, независимо от режима маскирования системы.
|
||||||
|
*
|
||||||
|
* <p>Контракт проверяющей системы это плечо не затрагивает: он живёт в
|
||||||
|
* {@link ProcessResource}.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class ProxyResource {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ProxyResource.class);
|
||||||
|
|
||||||
|
public record ProxyRequest(@JsonProperty("prompt") String prompt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ProxyResponse(
|
||||||
|
@JsonProperty("prompt_masked") String promptMasked,
|
||||||
|
@JsonProperty("llm_response_masked") String llmResponseMasked,
|
||||||
|
@JsonProperty("response") String response,
|
||||||
|
@JsonProperty("replaced") Map<String, String> replaced,
|
||||||
|
@JsonProperty("llm") String llm,
|
||||||
|
@JsonProperty("error") String error) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Pipeline pipeline;
|
||||||
|
private final SystemsConfig systems;
|
||||||
|
private final LlmClient llm;
|
||||||
|
|
||||||
|
public ProxyResource(Pipeline pipeline, SystemsConfig systems, LlmClient llm) {
|
||||||
|
this.pipeline = pipeline;
|
||||||
|
this.systems = systems;
|
||||||
|
this.llm = llm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/proxy")
|
||||||
|
public ResponseEntity<ProxyResponse> proxy(@RequestBody(required = false) ProxyRequest request,
|
||||||
|
@RequestHeader(value = ProcessResource.SYSTEM_HEADER, required = false) String systemId,
|
||||||
|
@RequestHeader(value = ProcessResource.KEY_HEADER, required = false) String systemKey) {
|
||||||
|
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(new ProxyResponse(null, null, null, null, null, "поле prompt обязательно"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemPolicy policy = systems.policyFor(systemId);
|
||||||
|
if (!policy.accepts(systemKey)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(new ProxyResponse(null, null, null, null, null, "Неверный ключ системы"));
|
||||||
|
}
|
||||||
|
if (!policy.enabled()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(new ProxyResponse(null, null, null, null, null,
|
||||||
|
"Системе " + systemId + " обращение в модуль запрещено"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Pipeline.Masked masked = pipeline.maskWithRestorations(request.prompt(), policy);
|
||||||
|
LlmClient.Answer answer = llm.ask(masked.text());
|
||||||
|
String restored = policy.demask() ? restore(answer.text(), masked.restorations()) : answer.text();
|
||||||
|
|
||||||
|
LOG.info("proxy: система={} заменено={} модель={}",
|
||||||
|
policy.name(), masked.restorations().size(), answer.source());
|
||||||
|
return ResponseEntity.ok(new ProxyResponse(masked.text(), answer.text(), restored,
|
||||||
|
masked.restorations(), answer.source(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает исходные значения на место подстановок в ответе модели. */
|
||||||
|
private static String restore(String text, Map<String, String> restorations) {
|
||||||
|
String result = text;
|
||||||
|
for (Map.Entry<String, String> entry : restorations.entrySet()) {
|
||||||
|
result = result.replace(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package ru.pdguard.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import ru.pdguard.detect.NameDictionary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конфигурация словарей распознавания ФИО.
|
||||||
|
*
|
||||||
|
* <p>{@link NameDictionary} работает через статические методы и не требует
|
||||||
|
* экземпляра, но путь к внешнему файлу денилиста задаётся из настроек при
|
||||||
|
* старте. Бин здесь нужен только для того, чтобы Spring подставил значение
|
||||||
|
* {@code pdguard.well-known-file} и передал его словарю.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class DictionaryConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public NameDictionary nameDictionary(
|
||||||
|
@Value("${pdguard.well-known-file:config/well-known.txt}") String wellKnownFile) {
|
||||||
|
NameDictionary.configure(wellKnownFile);
|
||||||
|
return NameDictionary.create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package ru.pdguard.config;
|
||||||
|
|
||||||
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правила обработки для одной системы-потребителя.
|
||||||
|
*
|
||||||
|
* @param name имя системы; им же разделяется хранилище соответствий,
|
||||||
|
* чтобы одна система не могла достать данные другой
|
||||||
|
* @param enabled разрешено ли системе обращаться в модуль
|
||||||
|
* @param demask выполняется ли для системы обратное преобразование
|
||||||
|
* @param maskMode вид замены: звёздочки, токен или синтетическое значение
|
||||||
|
* @param types типы ПД к маскированию; {@code "*"} — все известные
|
||||||
|
* @param key общий секрет системы; задан — заголовок {@code X-System-Key} обязан
|
||||||
|
* совпасть, иначе имя системы можно было бы просто назвать.
|
||||||
|
* Только знаки ASCII: заголовки HTTP передаются в Latin-1,
|
||||||
|
* и кириллица в ключе до сервиса доедет искажённой
|
||||||
|
* @param requireCompanion типы, которые маскируются только вместе с ПД другого типа:
|
||||||
|
* пин-код сам по себе безвреден, пин-код рядом с номером
|
||||||
|
* карты — уже нет; то же для даты без якорного слова, места
|
||||||
|
* рождения («Нижний Новгород» в рассказе о городе — не адрес
|
||||||
|
* клиента) и страны («цены выросли в Казахстане» — не гражданство).
|
||||||
|
* Сюда же банковские реквизиты — счёт, БИК, ОГРН, ОГРНИП, КПП:
|
||||||
|
* сами по себе они опознают организацию или счёт, а не человека,
|
||||||
|
* и в перечне типов из задания их нет. Рядом с именем клиента
|
||||||
|
* они становятся его данными и маскируются.
|
||||||
|
* Сюда же доход и биометрия. Сумма заработка без человека —
|
||||||
|
* статистика («доход домохозяйств вырос до 74 500 руб»), а не
|
||||||
|
* персональные данные. Биометрия же в тексте не встречается
|
||||||
|
* вовсе: это шаблон в базе, и правило маскирует лишь само
|
||||||
|
* упоминание, то есть слово, а не данные. Чувствителен здесь
|
||||||
|
* факт, что биометрию сдал названный человек, — а он и
|
||||||
|
* существует только при имени рядом
|
||||||
|
*/
|
||||||
|
public record SystemPolicy(String name, boolean enabled, boolean demask, MaskMode maskMode,
|
||||||
|
Set<String> types, Set<String> requireCompanion, String key) {
|
||||||
|
|
||||||
|
public static final String ALL = "*";
|
||||||
|
|
||||||
|
/** Имя политики по умолчанию; оно же разделяет хранилище для запросов без заголовка. */
|
||||||
|
public static final String DEFAULT_NAME = "default";
|
||||||
|
|
||||||
|
/** Политика по умолчанию: маскируем всё, что умеем, обратное преобразование включено. */
|
||||||
|
public static final SystemPolicy DEFAULT = new SystemPolicy(
|
||||||
|
DEFAULT_NAME, true, true, MaskMode.MASK, Set.of(ALL),
|
||||||
|
Set.of("CVV", "PIN", "DATE", "BIRTH_PLACE", "ADDRESS_COUNTRY",
|
||||||
|
"ACCOUNT_NUMBER", "BIK", "OGRN", "OGRNIP", "KPP",
|
||||||
|
"INCOME", "BIOMETRIC"), null);
|
||||||
|
|
||||||
|
public SystemPolicy {
|
||||||
|
types = Set.copyOf(types);
|
||||||
|
requireCompanion = Set.copyOf(requireCompanion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Политика только для перечисленных типов, с остальными настройками по умолчанию. */
|
||||||
|
public static SystemPolicy forTypes(String... types) {
|
||||||
|
return new SystemPolicy(DEFAULT_NAME, true, true, MaskMode.MASK,
|
||||||
|
Set.of(types), DEFAULT.requireCompanion(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Совпадает ли предъявленный ключ. Ключ не задан — проверка не применяется. */
|
||||||
|
public boolean accepts(String presentedKey) {
|
||||||
|
if (key == null || key.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return java.security.MessageDigest.isEqual(
|
||||||
|
key.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||||
|
(presentedKey == null ? "" : presentedKey).getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean allows(String type) {
|
||||||
|
return types.contains(ALL) || types.contains(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean needsCompanion(String type) {
|
||||||
|
return requireCompanion.contains(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package ru.pdguard.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список систем, которым разрешено обращаться в модуль, и правила для каждой.
|
||||||
|
*
|
||||||
|
* <p>Читается из внешнего файла, чтобы настройки менялись без пересборки. Файл
|
||||||
|
* перечитывается сам, когда меняется время его изменения; проверка выполняется
|
||||||
|
* не чаще раза в секунду, чтобы не ходить в файловую систему на каждом запросе.
|
||||||
|
* Файла нет — работают настройки по умолчанию, и сервис поднимается без него.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SystemsConfig {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(SystemsConfig.class);
|
||||||
|
|
||||||
|
/** Имя политики, которая применяется к запросам без заголовка системы. */
|
||||||
|
public static final String DEFAULT_SYSTEM = "default";
|
||||||
|
|
||||||
|
private static final long RECHECK_MILLIS = 1000;
|
||||||
|
|
||||||
|
/** Описание одной системы в файле настроек. */
|
||||||
|
public record SystemEntry(Boolean enabled, Boolean demask, String maskMode,
|
||||||
|
List<String> types, List<String> requireCompanion, String key) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Path file;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
private final AtomicReference<Map<String, SystemPolicy>> policies =
|
||||||
|
new AtomicReference<>(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
|
||||||
|
private volatile long fileTimestamp;
|
||||||
|
private volatile long lastCheck;
|
||||||
|
|
||||||
|
public SystemsConfig(@Value("${pdguard.systems-file:config/systems.json}") String path,
|
||||||
|
ObjectMapper mapper) {
|
||||||
|
this.file = Path.of(path);
|
||||||
|
this.mapper = mapper;
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Правила для системы; неизвестная система получает настройки по умолчанию. */
|
||||||
|
public SystemPolicy policyFor(String systemId) {
|
||||||
|
refreshIfChanged();
|
||||||
|
Map<String, SystemPolicy> current = policies.get();
|
||||||
|
SystemPolicy policy = systemId == null ? null : current.get(systemId);
|
||||||
|
if (policy != null) {
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
return current.getOrDefault(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Известна ли система по имени. */
|
||||||
|
public boolean isKnown(String systemId) {
|
||||||
|
refreshIfChanged();
|
||||||
|
return systemId != null && policies.get().containsKey(systemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущие настройки — для отдачи в административном интерфейсе. */
|
||||||
|
public Map<String, SystemPolicy> current() {
|
||||||
|
refreshIfChanged();
|
||||||
|
return new TreeMap<>(policies.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перечитать файл настроек немедленно. */
|
||||||
|
public final synchronized void reload() {
|
||||||
|
lastCheck = System.currentTimeMillis();
|
||||||
|
if (!Files.isReadable(file)) {
|
||||||
|
LOG.info("Файл настроек {} не найден, применяются настройки по умолчанию", file.toAbsolutePath());
|
||||||
|
policies.set(Map.of(DEFAULT_SYSTEM, SystemPolicy.DEFAULT));
|
||||||
|
fileTimestamp = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fileTimestamp = Files.getLastModifiedTime(file).toMillis();
|
||||||
|
Map<String, SystemEntry> entries = mapper.readValue(Files.readAllBytes(file),
|
||||||
|
mapper.getTypeFactory().constructMapType(TreeMap.class, String.class, SystemEntry.class));
|
||||||
|
Map<String, SystemPolicy> parsed = new TreeMap<>();
|
||||||
|
entries.forEach((name, entry) -> parsed.put(name, toPolicy(name, entry)));
|
||||||
|
parsed.putIfAbsent(DEFAULT_SYSTEM, SystemPolicy.DEFAULT);
|
||||||
|
policies.set(Map.copyOf(parsed));
|
||||||
|
LOG.info("Настройки систем перечитаны из {}: {}", file.toAbsolutePath(), parsed.keySet());
|
||||||
|
} catch (IOException | IllegalArgumentException e) {
|
||||||
|
// Битый файл не должен ронять работающий сервис: остаются прежние настройки.
|
||||||
|
LOG.error("Не удалось прочитать {}, продолжаем с прежними настройками", file.toAbsolutePath(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshIfChanged() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastCheck < RECHECK_MILLIS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCheck = now;
|
||||||
|
try {
|
||||||
|
if (!Files.isReadable(file)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Files.getLastModifiedTime(file).toMillis() != fileTimestamp) {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.debug("Не удалось проверить время изменения {}", file, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SystemPolicy toPolicy(String name, SystemEntry entry) {
|
||||||
|
SystemPolicy base = SystemPolicy.DEFAULT;
|
||||||
|
Set<String> types = entry.types() == null ? base.types() : new HashSet<>(entry.types());
|
||||||
|
Set<String> companions = entry.requireCompanion() == null
|
||||||
|
? base.requireCompanion() : new HashSet<>(entry.requireCompanion());
|
||||||
|
MaskMode mode = entry.maskMode() == null
|
||||||
|
? base.maskMode() : MaskMode.valueOf(entry.maskMode().toUpperCase(Locale.ROOT));
|
||||||
|
return new SystemPolicy(name,
|
||||||
|
entry.enabled() == null || entry.enabled(),
|
||||||
|
entry.demask() == null || entry.demask(),
|
||||||
|
mode, types, companions, entry.key());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предел одновременных запросов, который сам подстраивается под задержку,
|
||||||
|
* а не задан фиксированным числом. Растёт, пока обработка укладывается в
|
||||||
|
* целевое время, и сжимается, как только перестаёт — вместо того чтобы
|
||||||
|
* копить очередь и подходить к таймауту вызывающей стороны.
|
||||||
|
*
|
||||||
|
* <p>Число CPU контейнеру намеренно не спрашивается: {@code Runtime.
|
||||||
|
* availableProcessors()} под квотой {@code --cpus} в cgroups не меняется
|
||||||
|
* (это не affinity, а квота), поэтому в контейнере с долей ядра оно
|
||||||
|
* показывает все ядра хоста и как источник предела не годится. Задержка —
|
||||||
|
* наблюдаемое следствие реальной доли CPU, а не догадка о её размере.
|
||||||
|
*
|
||||||
|
* <p>Шаг регулировки привязан к времени, не к числу запросов: при первой
|
||||||
|
* версии предел менялся на каждый завершённый запрос, и на высоком RPS
|
||||||
|
* тысячи «быстрых» замеров прилетали за миллисекунды — предел успевал
|
||||||
|
* разогнаться до потолка ещё до того, как перегрузка вообще проявлялась,
|
||||||
|
* и то же самое повторялось после каждого восстановления. Проверено
|
||||||
|
* нагрузочным тестом: без привязки к времени p95 на перегрузке доходил
|
||||||
|
* до 1,8–2,3 с при 0,5 CPU, хотя предел вроде бы должен был сжаться.
|
||||||
|
* Не чаще, чем раз в {@link #ADJUST_WINDOW_NANOS}, предел меняется одним
|
||||||
|
* шагом на основе среднего за окно — так скорость регулировки не зависит
|
||||||
|
* от того, насколько высок входящий RPS.
|
||||||
|
*
|
||||||
|
* <p>Рост — на единицу за окно (AIMD), не удвоением. Удвоение (slow start
|
||||||
|
* из TCP) здесь не подходит: там обратная связь — RTT, миллисекунды, и
|
||||||
|
* лишний виток роста стоит дёшево. Здесь обратная связь — время ответа
|
||||||
|
* заявки, и под перегрузкой оно само составляет секунды: предел успевает
|
||||||
|
* удвоиться несколько раз (2→4→8→…→сотни) быстрее, чем придёт первый
|
||||||
|
* сигнал о деградации, и уже принятые заявки не исчезают из очереди, даже
|
||||||
|
* если следующим окном предел тут же обрушить. Проверено нагрузочным
|
||||||
|
* тестом: с удвоением p95 на перегрузке всё равно доходил до 1,8–2,2 с.
|
||||||
|
* Линейный рост копит риск медленно, и первый плохой сигнал останавливает
|
||||||
|
* его на порядок раньше. Сжатие — вдвое, а не на единицу: на перегрузке
|
||||||
|
* дешевле один раз отрезать с запасом, чем несколько окон подряд плавно
|
||||||
|
* подходить к безопасному уровню, пока заявки продолжают копиться.
|
||||||
|
*
|
||||||
|
* <p>ponytail: счётчики окна суммируются без блокировки — гонка на границе
|
||||||
|
* окна может добавить образец в уже подводимый итог или отбросить один,
|
||||||
|
* не больше; при масштабах в десятки-сотни образцов на окно это не видно.
|
||||||
|
* Нужен точный регулятор — взять готовую библиотеку вроде Netflix
|
||||||
|
* {@code concurrency-limits} (Vegas/Gradient2); здесь она не взята из
|
||||||
|
* осторожности к GraalVM native-image: незнакомая рефлексия в чужой
|
||||||
|
* библиотеке — это ровно тот класс проблем, из-за которого модели второй
|
||||||
|
* ступени понадобилась отдельная настройка сборки.
|
||||||
|
*/
|
||||||
|
public class AdaptiveConcurrencyLimiter {
|
||||||
|
|
||||||
|
private static final long DEFAULT_ADJUST_WINDOW_NANOS = TimeUnit.MILLISECONDS.toNanos(20);
|
||||||
|
|
||||||
|
private final AtomicInteger inFlight = new AtomicInteger();
|
||||||
|
private final AtomicLong windowSumNanos = new AtomicLong();
|
||||||
|
private final AtomicInteger windowSamples = new AtomicInteger();
|
||||||
|
private final AtomicLong lastAdjustNanos;
|
||||||
|
private final int minLimit;
|
||||||
|
private final int maxLimit;
|
||||||
|
private final long targetLatencyNanos;
|
||||||
|
private final long adjustWindowNanos;
|
||||||
|
private final AtomicInteger limit;
|
||||||
|
|
||||||
|
public AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis) {
|
||||||
|
this(minLimit, maxLimit, targetLatencyMillis, DEFAULT_ADJUST_WINDOW_NANOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Настраиваемое окно регулировки — для тестов, которым реальные 20мс на шаг не подходят. */
|
||||||
|
AdaptiveConcurrencyLimiter(int minLimit, int maxLimit, long targetLatencyMillis, long adjustWindowNanos) {
|
||||||
|
if (minLimit < 1 || maxLimit < minLimit) {
|
||||||
|
throw new IllegalArgumentException("Некорректные границы предела: " + minLimit + ".." + maxLimit);
|
||||||
|
}
|
||||||
|
this.minLimit = minLimit;
|
||||||
|
this.maxLimit = maxLimit;
|
||||||
|
this.targetLatencyNanos = TimeUnit.MILLISECONDS.toNanos(targetLatencyMillis);
|
||||||
|
this.adjustWindowNanos = adjustWindowNanos;
|
||||||
|
this.limit = new AtomicInteger(minLimit);
|
||||||
|
this.lastAdjustNanos = new AtomicLong(System.nanoTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code true} — запрос принят; вызывающая сторона обязана вызвать {@link #release}. */
|
||||||
|
public boolean tryAcquire() {
|
||||||
|
if (inFlight.incrementAndGet() > limit.get()) {
|
||||||
|
inFlight.decrementAndGet();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Освобождает слот; предел подстраивается не чаще раза в окно, а не на каждый вызов. */
|
||||||
|
public void release(long elapsedNanos) {
|
||||||
|
inFlight.decrementAndGet();
|
||||||
|
windowSumNanos.addAndGet(elapsedNanos);
|
||||||
|
windowSamples.incrementAndGet();
|
||||||
|
|
||||||
|
long now = System.nanoTime();
|
||||||
|
long last = lastAdjustNanos.get();
|
||||||
|
if (now - last >= adjustWindowNanos && lastAdjustNanos.compareAndSet(last, now)) {
|
||||||
|
adjust();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void adjust() {
|
||||||
|
int samples = windowSamples.getAndSet(0);
|
||||||
|
long sum = windowSumNanos.getAndSet(0);
|
||||||
|
if (samples == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long avg = sum / samples;
|
||||||
|
|
||||||
|
if (avg < targetLatencyNanos) {
|
||||||
|
limit.set(Math.min(maxLimit, limit.get() + 1));
|
||||||
|
} else {
|
||||||
|
limit.set(Math.max(minLimit, limit.get() / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько запросов обрабатывается прямо сейчас — для наблюдения. */
|
||||||
|
public int inFlight() {
|
||||||
|
return inFlight.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущий предел — для метрики, чтобы деградацию было видно, а не только чувствовать по 429. */
|
||||||
|
public int limit() {
|
||||||
|
return limit.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обращение к языковой модели для демонстрационного плеча.
|
||||||
|
*
|
||||||
|
* <p>Адрес не задан — работает заглушка: она возвращает присланный текст обратно.
|
||||||
|
* Для демонстрации этого достаточно, потому что проверяется не качество ответа
|
||||||
|
* модели, а то, что в модель ушёл замаскированный текст, а потребителю вернулся
|
||||||
|
* восстановленный.
|
||||||
|
*
|
||||||
|
* <p>Модель недоступна или ответила ошибкой — плечо деградирует до той же заглушки,
|
||||||
|
* а причина попадает в ответ и в журнал. Ронять запрос из-за внешнего сервиса нельзя.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class LlmClient {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(LlmClient.class);
|
||||||
|
|
||||||
|
/** Что вернула модель и кто именно ответил. */
|
||||||
|
public record Answer(String text, String source) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Optional<String> url;
|
||||||
|
private final Optional<String> apiKey;
|
||||||
|
private final String model;
|
||||||
|
private final Duration timeout;
|
||||||
|
private final HttpClient http;
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public LlmClient(
|
||||||
|
@Value("${pdguard.llm.url:}") String url,
|
||||||
|
@Value("${pdguard.llm.api-key:}") String apiKey,
|
||||||
|
@Value("${pdguard.llm.model:gpt-4o-mini}") String model,
|
||||||
|
@Value("${pdguard.llm.timeout-seconds:20}") int timeoutSeconds) {
|
||||||
|
this.url = Optional.ofNullable(url).filter(value -> !value.isBlank());
|
||||||
|
this.apiKey = Optional.ofNullable(apiKey).filter(value -> !value.isBlank());
|
||||||
|
this.model = model;
|
||||||
|
this.timeout = Duration.ofSeconds(timeoutSeconds);
|
||||||
|
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||||
|
if (this.url.isEmpty()) {
|
||||||
|
LOG.info("Адрес языковой модели не задан, плечо работает на заглушке");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Answer ask(String maskedPrompt) {
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
return new Answer(stub(maskedPrompt), "заглушка");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Answer(call(maskedPrompt), model);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("Языковая модель недоступна, плечо ответило заглушкой", e);
|
||||||
|
return new Answer(stub(maskedPrompt), "заглушка: модель недоступна");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ответ содержит присланный текст целиком: так на демонстрации видно, что
|
||||||
|
* подстановки вернулись на свои места при обратном преобразовании.
|
||||||
|
*/
|
||||||
|
private static String stub(String maskedPrompt) {
|
||||||
|
return "Ответ по запросу: " + maskedPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String call(String maskedPrompt) throws Exception {
|
||||||
|
ObjectNode body = mapper.createObjectNode();
|
||||||
|
body.put("model", model);
|
||||||
|
ObjectNode message = body.putArray("messages").addObject();
|
||||||
|
message.put("role", "user");
|
||||||
|
message.put("content", maskedPrompt);
|
||||||
|
|
||||||
|
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url.get()))
|
||||||
|
.timeout(timeout)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(
|
||||||
|
mapper.writeValueAsString(body), StandardCharsets.UTF_8));
|
||||||
|
apiKey.ifPresent(key -> request.header("Authorization", "Bearer " + key));
|
||||||
|
|
||||||
|
HttpResponse<String> response = http.send(request.build(),
|
||||||
|
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
throw new IllegalStateException("модель ответила " + response.statusCode());
|
||||||
|
}
|
||||||
|
return mapper.readTree(response.body())
|
||||||
|
.path("choices").path(0).path("message").path("content").asText();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.Meter;
|
||||||
|
import io.micrometer.core.instrument.config.MeterFilter;
|
||||||
|
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Настройка распределений для метрик времени.
|
||||||
|
*
|
||||||
|
* <p>По умолчанию Micrometer отдаёт по таймеру только сумму, количество и максимум.
|
||||||
|
* Этого хватает на среднее, но не на перцентили, а именно они описывают SLA: важно
|
||||||
|
* не среднее время ответа, а то, сколько запросов уложилось в срок. Гистограмма
|
||||||
|
* добавляет ряды по корзинам, и {@code histogram_quantile} в Prometheus считает по
|
||||||
|
* ним p50, p95 и p99.
|
||||||
|
*
|
||||||
|
* <p>Границы корзин заданы явно и подобраны под наши задержки: от четверти
|
||||||
|
* миллисекунды до десяти секунд. Без явных границ Micrometer создаёт их сам и
|
||||||
|
* заметно больше, а каждая корзина — это отдельный временной ряд на каждое
|
||||||
|
* сочетание меток.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class MetricsConfiguration {
|
||||||
|
|
||||||
|
/** Целевая задержка из критериев оценки: ориентир, а не жёсткий предел. */
|
||||||
|
private static final Duration SLA_TARGET = Duration.ofMillis(500);
|
||||||
|
|
||||||
|
private static final Duration[] BOUNDARIES = {
|
||||||
|
Duration.ofNanos(250_000), Duration.ofMillis(1), Duration.ofMillis(5),
|
||||||
|
Duration.ofMillis(10), Duration.ofMillis(25), Duration.ofMillis(50),
|
||||||
|
Duration.ofMillis(100), Duration.ofMillis(250), SLA_TARGET,
|
||||||
|
Duration.ofSeconds(1), Duration.ofSeconds(2), Duration.ofSeconds(5),
|
||||||
|
Duration.ofSeconds(10)
|
||||||
|
};
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MeterFilter histogramsForTimers() {
|
||||||
|
return new MeterFilter() {
|
||||||
|
@Override
|
||||||
|
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
|
||||||
|
if (!needsHistogram(id.getName())) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
double[] boundaries = new double[BOUNDARIES.length];
|
||||||
|
for (int i = 0; i < BOUNDARIES.length; i++) {
|
||||||
|
boundaries[i] = BOUNDARIES[i].toNanos();
|
||||||
|
}
|
||||||
|
return DistributionStatisticConfig.builder()
|
||||||
|
.percentilesHistogram(false)
|
||||||
|
.serviceLevelObjectives(boundaries)
|
||||||
|
.build()
|
||||||
|
.merge(config);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean needsHistogram(String name) {
|
||||||
|
return "pdguard.process".equals(name)
|
||||||
|
|| "pdguard.ner.duration".equals(name)
|
||||||
|
|| "http.server.requests".equals(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Соответствие «исходный текст ↔ маска», по которому выполняется демаскирование.
|
||||||
|
*
|
||||||
|
* <p>Два индекса: по {@code payload_id} — основной путь, и по отпечатку маски —
|
||||||
|
* страховка на случай, если идентификатор до сервиса не доехал.
|
||||||
|
*
|
||||||
|
* <p>Оба индекса разделены по системам-потребителям. Индекс по отпечатку ищет
|
||||||
|
* совпадение по самому тексту запроса, и без такого разделения он превращался бы в
|
||||||
|
* способ достать чужие данные: маски детерминированы и низкоэнтропийны, поэтому,
|
||||||
|
* прислав «Клиент И. И. И., паспорт 45** ****56», можно было бы получить в ответ
|
||||||
|
* исходные значения из запроса другого потребителя. Разделение ограничивает это
|
||||||
|
* пределами одной системы, которая и так видит свои данные.
|
||||||
|
*
|
||||||
|
* <p>Хранилище ограничено по суммарному объёму строк, а записи живут ограниченное
|
||||||
|
* время: персональные данные не должны залёживаться в памяти, а крупные тексты не
|
||||||
|
* должны исчерпать кучу. Вытеснение идёт в порядке добавления и выполняется прямо
|
||||||
|
* на записи — отдельного потока и внешней библиотеки кеширования не требуется.
|
||||||
|
*
|
||||||
|
* <p>Когда включён общий слой ({@link SharedIndex}), соответствие пишется ещё и туда,
|
||||||
|
* а чтение при промахе по локальной памяти идёт в него. Это нужно при работе на
|
||||||
|
* нескольких узлах: обратный запрос легко попадает не на тот узел, который выполнял
|
||||||
|
* прямой. Локальная память при этом остаётся первым уровнем, и обычный путь
|
||||||
|
* обходится без обращения по сети.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PayloadStore {
|
||||||
|
|
||||||
|
/** Сколько протухших записей просматривается за одну операцию записи. */
|
||||||
|
private static final int SWEEP_PER_PUT = 4;
|
||||||
|
|
||||||
|
/** Пара «исходный текст — маска» с отпечатком, владельцем и сроком жизни. */
|
||||||
|
public record Entry(String system, String original, String masked,
|
||||||
|
String fingerprint, long expiresAt) {
|
||||||
|
|
||||||
|
boolean alive(long now) {
|
||||||
|
return now < expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
int weight() {
|
||||||
|
return original.length() + masked.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Map<String, Entry> byId = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Entry> byMaskFingerprint = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentLinkedQueue<String> insertionOrder = new ConcurrentLinkedQueue<>();
|
||||||
|
private final AtomicLong charsHeld = new AtomicLong();
|
||||||
|
|
||||||
|
private final long maxChars;
|
||||||
|
private final long ttlMillis;
|
||||||
|
private final SharedIndex shared;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public PayloadStore(
|
||||||
|
@Value("${pdguard.store.max-chars:134217728}") long maxChars,
|
||||||
|
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
||||||
|
SharedIndex shared) {
|
||||||
|
this.maxChars = maxChars;
|
||||||
|
this.ttlMillis = ttlMinutes * 60_000L;
|
||||||
|
this.shared = shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов: только локальная память, общий слой выключен. */
|
||||||
|
public PayloadStore(long maxChars, int ttlMinutes) {
|
||||||
|
this(maxChars, ttlMinutes, SharedIndex.disabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void put(String system, String payloadId, String original, String masked) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Entry entry = new Entry(system, original, masked, fingerprint(masked), now + ttlMillis);
|
||||||
|
String idKey = scoped(system, payloadId);
|
||||||
|
|
||||||
|
Entry replaced = byId.put(idKey, entry);
|
||||||
|
byMaskFingerprint.put(scoped(system, entry.fingerprint()), entry);
|
||||||
|
insertionOrder.add(idKey);
|
||||||
|
charsHeld.addAndGet((long) entry.weight() - (replaced == null ? 0 : replaced.weight()));
|
||||||
|
|
||||||
|
sweepExpired(now);
|
||||||
|
evictWhileOverLimit();
|
||||||
|
|
||||||
|
shared.put(system, payloadId, original, masked, entry.fingerprint());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Entry byId(String system, String payloadId) {
|
||||||
|
String idKey = scoped(system, payloadId);
|
||||||
|
Entry entry = byId.get(idKey);
|
||||||
|
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
if (entry != null) {
|
||||||
|
forget(idKey, entry);
|
||||||
|
}
|
||||||
|
SharedIndex.SharedEntry fromShared = shared.byId(system, payloadId);
|
||||||
|
if (fromShared == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Соседний узел уже выполнял прямой шаг: забираем соответствие к себе,
|
||||||
|
// чтобы повторное обращение обошлось без сети.
|
||||||
|
put(system, payloadId, fromShared.original(), fromShared.masked());
|
||||||
|
return byId.get(idKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исходный текст по самой маске — когда {@code payload_id} не совпал. Поиск идёт
|
||||||
|
* только в пределах той же системы: чужую маску подобрать и обменять на исходные
|
||||||
|
* данные нельзя.
|
||||||
|
*/
|
||||||
|
public String originalForMask(String system, String masked) {
|
||||||
|
String fingerprint = fingerprint(masked);
|
||||||
|
Entry entry = byMaskFingerprint.get(scoped(system, fingerprint));
|
||||||
|
if (entry != null && entry.alive(System.currentTimeMillis())) {
|
||||||
|
return entry.original();
|
||||||
|
}
|
||||||
|
return shared.originalForFingerprint(system, fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сколько символов сейчас удерживается — для диагностики и тестов. */
|
||||||
|
public long charsHeld() {
|
||||||
|
return charsHeld.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ, однозначно разделяющий системы. Длина имени в начале снимает вопрос о
|
||||||
|
* разделителе: имя системы может содержать любые знаки.
|
||||||
|
*/
|
||||||
|
private static String scoped(String system, String key) {
|
||||||
|
String owner = system == null ? "" : system;
|
||||||
|
return owner.length() + ":" + owner + ":" + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Убирает протухшие записи с головы очереди, не более нескольких за раз. */
|
||||||
|
private void sweepExpired(long now) {
|
||||||
|
for (int i = 0; i < SWEEP_PER_PUT; i++) {
|
||||||
|
String oldest = insertionOrder.peek();
|
||||||
|
if (oldest == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Entry entry = byId.get(oldest);
|
||||||
|
if (entry == null) {
|
||||||
|
insertionOrder.poll();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.alive(now)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertionOrder.poll();
|
||||||
|
forget(oldest, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void evictWhileOverLimit() {
|
||||||
|
while (charsHeld.get() > maxChars) {
|
||||||
|
String oldest = insertionOrder.poll();
|
||||||
|
if (oldest == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Entry entry = byId.get(oldest);
|
||||||
|
if (entry != null) {
|
||||||
|
// ponytail: если тот же payload_id записали повторно, в очереди остался
|
||||||
|
// старый след и здесь вытесняется свежая запись. Цена — одно лишнее
|
||||||
|
// обращение к маскированию; точный учёт потребовал бы двусвязного списка.
|
||||||
|
forget(oldest, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void forget(String idKey, Entry entry) {
|
||||||
|
if (byId.remove(idKey, entry)) {
|
||||||
|
byMaskFingerprint.remove(scoped(entry.system(), entry.fingerprint()), entry);
|
||||||
|
charsHeld.addAndGet(-entry.weight());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fingerprint(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest sha = MessageDigest.getInstance("SHA-256");
|
||||||
|
return HexFormat.of().formatHex(sha.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 недоступен в этой среде выполнения", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.Counter;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import io.micrometer.core.instrument.Timer;
|
||||||
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.NameDictionary;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
import ru.pdguard.mask.MaskContext;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.NavigableMap;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка одного обращения: поиск ПД, маскирование и обратное преобразование.
|
||||||
|
*
|
||||||
|
* <p>Направление определяется по {@code payload_id}, а не по содержимому запроса:
|
||||||
|
* <ul>
|
||||||
|
* <li>идентификатор неизвестен — маскируем;</li>
|
||||||
|
* <li>пришёл ранее выданный нами текст маски — возвращаем исходный текст;</li>
|
||||||
|
* <li>пришёл тот же исходный текст — возвращаем ту же маску, что и в первый раз.</li>
|
||||||
|
* </ul>
|
||||||
|
* Последний случай — повторная попытка проверяющей системы: ответ обязан
|
||||||
|
* совпасть с первым, иначе демаскирование по этому элементу развалится.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class Pipeline {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(Pipeline.class);
|
||||||
|
|
||||||
|
/** Грубая оценка числа токенов по числу символов — для метрики TPS. */
|
||||||
|
private static final int CHARS_PER_TOKEN = 4;
|
||||||
|
|
||||||
|
private final RuleRegistry registry;
|
||||||
|
private final Masker masker;
|
||||||
|
private final PayloadStore store;
|
||||||
|
private final MeterRegistry meters;
|
||||||
|
private final NameCascade cascade;
|
||||||
|
private final Counter tokensProcessed;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, MeterRegistry meters,
|
||||||
|
NameCascade cascade) {
|
||||||
|
this.registry = registry;
|
||||||
|
this.masker = masker;
|
||||||
|
this.store = store;
|
||||||
|
this.meters = meters;
|
||||||
|
this.cascade = cascade;
|
||||||
|
meters.gauge("pdguard.store.chars", store, PayloadStore::charsHeld);
|
||||||
|
this.tokensProcessed = Counter.builder("pdguard.tokens.processed")
|
||||||
|
.description("Оценка числа обработанных токенов, для расчёта TPS")
|
||||||
|
.register(meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов: метрики никуда не отдаются, вторая ступень выключена. */
|
||||||
|
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store) {
|
||||||
|
this(registry, masker, store, new SimpleMeterRegistry(), NameCascade.disabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов второй ступени. */
|
||||||
|
public Pipeline(RuleRegistry registry, Masker masker, PayloadStore store, NameCascade cascade) {
|
||||||
|
this(registry, masker, store, new SimpleMeterRegistry(), cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String process(String payload, String payloadId, SystemPolicy policy) {
|
||||||
|
long started = System.nanoTime();
|
||||||
|
tokensProcessed.increment((double) payload.length() / CHARS_PER_TOKEN);
|
||||||
|
|
||||||
|
PayloadStore.Entry known = store.byId(policy.name(), payloadId);
|
||||||
|
if (known != null) {
|
||||||
|
if (policy.demask() && payload.equals(known.masked())) {
|
||||||
|
LOG.debug("payload_id={} обратное преобразование по идентификатору", payloadId);
|
||||||
|
recordLatency("unmask", policy.name(), started);
|
||||||
|
return known.original();
|
||||||
|
}
|
||||||
|
if (payload.equals(known.original())) {
|
||||||
|
LOG.debug("payload_id={} повторная попытка, отдаём прежнюю маску", payloadId);
|
||||||
|
recordLatency("mask", policy.name(), started);
|
||||||
|
return known.masked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (policy.demask()) {
|
||||||
|
String original = store.originalForMask(policy.name(), payload);
|
||||||
|
if (original != null) {
|
||||||
|
LOG.debug("payload_id={} обратное преобразование по отпечатку маски", payloadId);
|
||||||
|
recordLatency("unmask", policy.name(), started);
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mask(payload, payloadId, policy, started);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Длительность обработки с разрезом по направлению и системе-потребителю.
|
||||||
|
* Метрики берутся из реестра по тегам: систем немного и они заданы настройками,
|
||||||
|
* поэтому разрастания рядов не будет, а разрез по потребителям виден сразу.
|
||||||
|
*/
|
||||||
|
private void recordLatency(String direction, String system, long startedNanos) {
|
||||||
|
Timer.builder("pdguard.process")
|
||||||
|
.description("Длительность обработки обращения")
|
||||||
|
.tag("direction", direction)
|
||||||
|
.tag("system", system)
|
||||||
|
.register(meters)
|
||||||
|
.record(System.nanoTime() - startedNanos, TimeUnit.NANOSECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фрагменты, которые будут замаскированы: поиск по правилам, разрешение
|
||||||
|
* перекрытий и все отсечения. Отдельный метод нужен, чтобы качество детекции
|
||||||
|
* можно было измерить, не разбирая замаскированный текст обратно.
|
||||||
|
*/
|
||||||
|
public List<Span> findPersonalData(String text, SystemPolicy policy) {
|
||||||
|
List<Span> spans = resolveOverlaps(registry.detect(text, policy));
|
||||||
|
if (policy.allows(RuleRegistry.FIO)) {
|
||||||
|
// Вторая ступень разбирает только то, что не покрыла первая.
|
||||||
|
spans = resolveOverlaps(cascade.addMissedNames(text, spans));
|
||||||
|
}
|
||||||
|
spans = dropOrganisationNames(text, spans);
|
||||||
|
spans = dropWellKnownNames(text, spans);
|
||||||
|
return dropLonelyCompanions(spans, policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mask(String payload, String payloadId, SystemPolicy policy, long started) {
|
||||||
|
List<Span> spans = findPersonalData(payload, policy);
|
||||||
|
String masked = apply(payload, spans, policy);
|
||||||
|
store.put(policy.name(), payloadId, payload, masked);
|
||||||
|
|
||||||
|
recordLatency("mask", policy.name(), started);
|
||||||
|
logFindings(policy.name(), payloadId, payload.length(), spans);
|
||||||
|
return masked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оставляет непересекающиеся фрагменты: при конфликте побеждает более
|
||||||
|
* приоритетный, при равном приоритете — более длинный.
|
||||||
|
*/
|
||||||
|
static List<Span> resolveOverlaps(List<Span> spans) {
|
||||||
|
List<Span> candidates = new ArrayList<>(spans);
|
||||||
|
candidates.sort(Comparator.comparingInt(Span::priority).reversed()
|
||||||
|
.thenComparing(Comparator.comparingInt(Span::length).reversed())
|
||||||
|
.thenComparingInt(Span::start));
|
||||||
|
|
||||||
|
// Принятые фрагменты не пересекаются и упорядочены по началу, поэтому
|
||||||
|
// кандидату достаточно сверить себя с ближайшим слева и ближайшим справа.
|
||||||
|
// Перебор всех принятых давал бы квадрат: на тексте в сотню тысяч токенов
|
||||||
|
// фрагментов набираются тысячи.
|
||||||
|
NavigableMap<Integer, Span> accepted = new TreeMap<>();
|
||||||
|
for (Span candidate : candidates) {
|
||||||
|
if (overlapsAccepted(accepted, candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accepted.put(candidate.start(), candidate);
|
||||||
|
}
|
||||||
|
return List.copyOf(accepted.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверяет, пересекается ли кандидат с ближайшим принятым слева или справа. */
|
||||||
|
private static boolean overlapsAccepted(NavigableMap<Integer, Span> accepted, Span candidate) {
|
||||||
|
Map.Entry<Integer, Span> before = accepted.floorEntry(candidate.start());
|
||||||
|
if (before != null && before.getValue().overlaps(candidate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Map.Entry<Integer, Span> after = accepted.ceilingEntry(candidate.start());
|
||||||
|
return after != null && after.getValue().overlaps(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает имена, стоящие в названиях организаций и объектов на карте:
|
||||||
|
* «Институт Склифосовского», «Музей Тропинина», «улица Королёва». Проверка
|
||||||
|
* не зависит от того, есть ли в тексте другие ПД: слово перед именем решает
|
||||||
|
* само по себе.
|
||||||
|
*/
|
||||||
|
static List<Span> dropOrganisationNames(String text, List<Span> spans) {
|
||||||
|
return spans.stream()
|
||||||
|
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|
||||||
|
|| !NameDictionary.precededByOrganisation(text, span.start()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает имена известных людей: «стихи Александра Пушкина» персональными
|
||||||
|
* данными не являются. Если же в тексте есть ПД другого типа, речь идёт о
|
||||||
|
* конкретном человеке, и имя остаётся замаскированным — однофамилец
|
||||||
|
* исторической фигуры защиту не теряет.
|
||||||
|
*/
|
||||||
|
static List<Span> dropWellKnownNames(String text, List<Span> spans) {
|
||||||
|
boolean otherPersonalDataPresent = spans.stream()
|
||||||
|
.anyMatch(span -> !RuleRegistry.FIO.equals(span.type()));
|
||||||
|
if (otherPersonalDataPresent) {
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
return spans.stream()
|
||||||
|
.filter(span -> !RuleRegistry.FIO.equals(span.type())
|
||||||
|
|| !NameDictionary.isWellKnown(text.substring(span.start(), span.end())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убирает типы, которые опасны только в сочетании с другими ПД.
|
||||||
|
* Пин-код в отрыве от номера карты не является персональными данными,
|
||||||
|
* рядом с номером карты — является.
|
||||||
|
*
|
||||||
|
* <p>Спутником считается только находка самостоятельного типа. Раньше здесь
|
||||||
|
* сравнивалось число различных типов, и два спутника заверяли друг друга:
|
||||||
|
* «Оплата 01.02.2025, ОГРН 1027700132195» маскировалась целиком, хотя человека
|
||||||
|
* в тексте нет, а дата и ОГРН по отдельности персональными данными не являются.
|
||||||
|
* Сочетание двух несамостоятельных типов самостоятельным не становится.
|
||||||
|
*/
|
||||||
|
static List<Span> dropLonelyCompanions(List<Span> spans, SystemPolicy policy) {
|
||||||
|
for (Span span : spans) {
|
||||||
|
if (!policy.needsCompanion(span.type())) {
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Дошли сюда — самостоятельных находок нет, а значит все оставшиеся спутники одиноки.
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Замаскированный текст вместе с таблицей обратной замены. */
|
||||||
|
public record Masked(String text, java.util.Map<String, String> restorations) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маскирует текст и отдаёт таблицу обратной замены.
|
||||||
|
*
|
||||||
|
* <p>Нужно для прокси к языковой модели: ответ модели — другой текст, и восстановить
|
||||||
|
* его целиком по идентификатору нельзя, замену приходится делать пофрагментно.
|
||||||
|
* Звёздочки для этого не годятся — одна и та же маска может отвечать разным
|
||||||
|
* значениям, — поэтому режим замены здесь всегда обратимый.
|
||||||
|
*/
|
||||||
|
public Masked maskWithRestorations(String text, SystemPolicy policy) {
|
||||||
|
SystemPolicy reversible = new SystemPolicy(policy.name(), policy.enabled(), policy.demask(),
|
||||||
|
ru.pdguard.mask.MaskMode.TOKEN, policy.types(), policy.requireCompanion(), policy.key());
|
||||||
|
List<Span> spans = findPersonalData(text, reversible);
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
return new Masked(text, java.util.Map.of());
|
||||||
|
}
|
||||||
|
MaskContext context = new MaskContext();
|
||||||
|
String masked = apply(text, spans, reversible, context);
|
||||||
|
logFindings(policy.name(), "proxy", text.length(), spans);
|
||||||
|
return new Masked(masked, context.restorations());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apply(String text, List<Span> spans, SystemPolicy policy) {
|
||||||
|
return apply(text, spans, policy, new MaskContext());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apply(String text, List<Span> spans, SystemPolicy policy, MaskContext context) {
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(text.length());
|
||||||
|
int cursor = 0;
|
||||||
|
for (Span span : spans) {
|
||||||
|
sb.append(text, cursor, span.start());
|
||||||
|
String value = text.substring(span.start(), span.end());
|
||||||
|
sb.append(masker.mask(span.type(), value, policy.maskMode(), context));
|
||||||
|
cursor = span.end();
|
||||||
|
}
|
||||||
|
sb.append(text, cursor, text.length());
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* В журнал и в метрики попадают только идентификатор, типы ПД и их количество.
|
||||||
|
* Сами значения не логируются ни на одном уровне.
|
||||||
|
*/
|
||||||
|
private void logFindings(String system, String payloadId, int length, List<Span> spans) {
|
||||||
|
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||||
|
for (Span span : spans) {
|
||||||
|
counts.merge(span.type(), 1, Integer::sum);
|
||||||
|
}
|
||||||
|
counts.forEach((type, count) ->
|
||||||
|
meters.counter("pdguard.pd.detected", "type", type, "system", system).increment(count));
|
||||||
|
LOG.info("payload_id={} символов={} найдено={}", payloadId, length, counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прогон обработки на старте, чтобы первые запросы не попадали на непрогретый код.
|
||||||
|
*
|
||||||
|
* <p>На JVM разница измерима: без прогрева первые десятки секунд нагрузки идут по
|
||||||
|
* интерпретируемому и наспех скомпилированному коду, и p95 оказывается примерно
|
||||||
|
* вдесятеро хуже установившегося. Несколько тысяч прогонов на старте занимают доли
|
||||||
|
* секунды и переводят горячий путь на оптимизирующий компилятор до того, как придут
|
||||||
|
* настоящие запросы.
|
||||||
|
*
|
||||||
|
* <p>Прогрев идёт через отдельный экземпляр обработки со своим короткоживущим
|
||||||
|
* хранилищем: настоящие соответствия «текст ↔ маска» замусорить нельзя.
|
||||||
|
*
|
||||||
|
* <p>Вторая ступень при прогреве выключена, и не только ради времени: её счётчики
|
||||||
|
* показывают долю запросов, дошедших до модели, а тысячи служебных прогонов эту
|
||||||
|
* долю исказили бы до неузнаваемости. Сама модель прогревается отдельно, при
|
||||||
|
* создании своего пула.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PipelineWarmup {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(PipelineWarmup.class);
|
||||||
|
|
||||||
|
/** Тексты подобраны так, чтобы задеть основные семейства правил. */
|
||||||
|
private static final String[] SAMPLES = {
|
||||||
|
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
||||||
|
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
||||||
|
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
||||||
|
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
||||||
|
"Напиши краткое описание продукта для рассылки клиентам банка",
|
||||||
|
};
|
||||||
|
|
||||||
|
private final RuleRegistry registry;
|
||||||
|
private final Masker masker;
|
||||||
|
private final int iterations;
|
||||||
|
|
||||||
|
public PipelineWarmup(RuleRegistry registry, Masker masker,
|
||||||
|
@Value("${pdguard.warmup-iterations:2000}") int iterations) {
|
||||||
|
this.registry = registry;
|
||||||
|
this.masker = masker;
|
||||||
|
this.iterations = iterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void warmup() {
|
||||||
|
if (iterations <= 0) {
|
||||||
|
LOG.info("Прогрев обработки отключён");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long started = System.nanoTime();
|
||||||
|
Pipeline scratch = new Pipeline(registry, masker, new PayloadStore(1_000_000L, 1),
|
||||||
|
NameCascade.disabled());
|
||||||
|
SystemPolicy policy = new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, MaskMode.MASK,
|
||||||
|
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
|
||||||
|
|
||||||
|
for (int i = 0; i < iterations; i++) {
|
||||||
|
String text = SAMPLES[i % SAMPLES.length];
|
||||||
|
String id = "warmup-" + i;
|
||||||
|
String masked = scratch.process(text, id, policy);
|
||||||
|
scratch.process(masked, id, policy);
|
||||||
|
}
|
||||||
|
LOG.info("Прогрев обработки: {} прогонов за {} мс",
|
||||||
|
iterations, (System.nanoTime() - started) / 1_000_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package ru.pdguard.core;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий слой соответствий «текст ↔ маска» для работы на нескольких узлах.
|
||||||
|
*
|
||||||
|
* <p>Маскирование — чистая функция, на любом узле даёт один и тот же результат.
|
||||||
|
* Обратное же преобразование требует состояния: если прямой запрос обработал
|
||||||
|
* один узел, а обратный попал на другой, соответствие должно быть общим.
|
||||||
|
*
|
||||||
|
* <p>Включается настройкой {@code pdguard.store.backend=redis}. Пока она не
|
||||||
|
* выставлена, к Redis не обращаются вовсе и зависимость остаётся неактивной.
|
||||||
|
*
|
||||||
|
* <p>Недоступность Redis не приводит к отказу: запись и чтение деградируют до
|
||||||
|
* локальной памяти узла, а ошибка попадает в журнал. Чтобы простой Redis не
|
||||||
|
* съедал время ответа, команды ограничены по времени настройкой
|
||||||
|
* {@code spring.data.redis.timeout}, а после нескольких подряд неудач общий слой
|
||||||
|
* временно перестают опрашивать вовсе.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SharedIndex {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(SharedIndex.class);
|
||||||
|
|
||||||
|
private static final String KEY_BY_ID = "pdg:id:";
|
||||||
|
private static final String KEY_BY_MASK = "pdg:mask:";
|
||||||
|
|
||||||
|
/** Сколько подряд неудач размыкает предохранитель. */
|
||||||
|
private static final int FAILURES_TO_OPEN = 3;
|
||||||
|
|
||||||
|
/** На сколько общий слой перестают опрашивать после размыкания. */
|
||||||
|
private static final long OPEN_MILLIS = 5_000;
|
||||||
|
|
||||||
|
/** Пара «исходный текст — маска», как она хранится в общем слое. */
|
||||||
|
public record SharedEntry(String original, String masked) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final boolean enabled;
|
||||||
|
private final Duration ttl;
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
private final AtomicInteger consecutiveFailures = new AtomicInteger();
|
||||||
|
private volatile long silentUntil;
|
||||||
|
private volatile boolean reported;
|
||||||
|
|
||||||
|
public SharedIndex(StringRedisTemplate redis,
|
||||||
|
@Value("${pdguard.store.backend:memory}") String backend,
|
||||||
|
@Value("${pdguard.store.ttl-minutes:30}") int ttlMinutes,
|
||||||
|
ObjectMapper mapper) {
|
||||||
|
this.redis = redis;
|
||||||
|
this.enabled = "redis".equalsIgnoreCase(backend);
|
||||||
|
this.ttl = Duration.ofMinutes(ttlMinutes);
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выключенный слой — для тестов и для сборки без Redis. */
|
||||||
|
public static SharedIndex disabled() {
|
||||||
|
return new SharedIndex(null, "memory", 30, new ObjectMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void put(String system, String payloadId, String original, String masked,
|
||||||
|
String maskFingerprint) {
|
||||||
|
if (unavailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
redis.opsForValue().set(KEY_BY_ID + scoped(system, payloadId),
|
||||||
|
toJson(new SharedEntry(original, masked)), ttl);
|
||||||
|
redis.opsForValue().set(KEY_BY_MASK + scoped(system, maskFingerprint), original, ttl);
|
||||||
|
noteSuccess();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
noteFailure("записать", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public SharedEntry byId(String system, String payloadId) {
|
||||||
|
if (unavailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String json = redis.opsForValue().get(KEY_BY_ID + scoped(system, payloadId));
|
||||||
|
noteSuccess();
|
||||||
|
return json == null ? null : fromJson(json);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
noteFailure("прочитать", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String originalForFingerprint(String system, String maskFingerprint) {
|
||||||
|
if (unavailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String original = redis.opsForValue().get(KEY_BY_MASK + scoped(system, maskFingerprint));
|
||||||
|
noteSuccess();
|
||||||
|
return original;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
noteFailure("прочитать", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(SharedEntry entry) {
|
||||||
|
try {
|
||||||
|
return mapper.writeValueAsString(entry);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("Не удалось сериализовать соответствие", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SharedEntry fromJson(String json) {
|
||||||
|
try {
|
||||||
|
return mapper.readValue(json, SharedEntry.class);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new IllegalStateException("Не удалось разобрать соответствие из общего слоя", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ с разделением по системам: длина имени в начале снимает вопрос о разделителе. */
|
||||||
|
private static String scoped(String system, String key) {
|
||||||
|
String owner = system == null ? "" : system;
|
||||||
|
return owner.length() + ":" + owner + ":" + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Общий слой выключен или предохранитель разомкнут. */
|
||||||
|
private boolean unavailable() {
|
||||||
|
return !enabled || System.currentTimeMillis() < silentUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void noteSuccess() {
|
||||||
|
if (consecutiveFailures.getAndSet(0) != 0) {
|
||||||
|
reported = false;
|
||||||
|
LOG.info("Общий слой снова доступен");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* После нескольких неудач подряд общий слой перестают опрашивать на несколько
|
||||||
|
* секунд: иначе каждый запрос платил бы таймаутом за недоступный Redis, а
|
||||||
|
* проверяющая система считает ответ дольше десяти секунд неответом.
|
||||||
|
*/
|
||||||
|
private void noteFailure(String action, RuntimeException cause) {
|
||||||
|
if (consecutiveFailures.incrementAndGet() >= FAILURES_TO_OPEN) {
|
||||||
|
silentUntil = System.currentTimeMillis() + OPEN_MILLIS;
|
||||||
|
}
|
||||||
|
if (!reported) {
|
||||||
|
reported = true;
|
||||||
|
LOG.error("Не удалось {} соответствие в общий слой, узел работает на своей памяти", action, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий приём для словарей, сравнивающих слово из текста с основой из списка:
|
||||||
|
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}).
|
||||||
|
*
|
||||||
|
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»,
|
||||||
|
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
|
||||||
|
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
|
||||||
|
* для них сравнение идёт по основе без неё.
|
||||||
|
*
|
||||||
|
* <p>Фамилии на «-ский» склоняются как прилагательное: окончание меняется
|
||||||
|
* целиком («Дзержинский» → «Дзержинского», «-ий» на «-ого», а не дописывается),
|
||||||
|
* поэтому для них отсечения одной буквы недостаточно — основа обрезается сразу
|
||||||
|
* до «ск». Для улиц в честь людей это не редкий случай, а основной: «улица
|
||||||
|
* Дзержинского», «улица Островского» пишутся только в родительном падеже,
|
||||||
|
* именительный там не встречается вообще.
|
||||||
|
*/
|
||||||
|
final class Declension {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Падежные окончания прилагательного склонения на «-ск-»: мужской, женский
|
||||||
|
* и средний род, все падежи. Проверяются от длинных к коротким — «-ского»
|
||||||
|
* не должно потеряться из-за более короткого совпадения на «-ким» и т.п.
|
||||||
|
*/
|
||||||
|
private static final String[] ADJECTIVE_ENDINGS = {
|
||||||
|
"ского", "скому", "ским", "ском", "скую", "ской", "скою", "ская", "ский"
|
||||||
|
};
|
||||||
|
|
||||||
|
private Declension() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отбрасывает у основы окончание, которое меняется по падежам: гласную —
|
||||||
|
* у обычных слов, целиком «-ск-»-окончание — у прилагательных фамилий.
|
||||||
|
* Слова короче четырёх букв не трогает — короткая основа и так шире
|
||||||
|
* большинства падежных форм.
|
||||||
|
*/
|
||||||
|
static String withoutInflectedEnding(String word) {
|
||||||
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
|
for (String ending : ADJECTIVE_ENDINGS) {
|
||||||
|
if (lower.length() > ending.length() && lower.endsWith(ending)) {
|
||||||
|
return lower.substring(0, lower.length() - ending.length() + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
||||||
|
return lower.substring(0, lower.length() - 1);
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import io.micrometer.core.instrument.Counter;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import io.micrometer.core.instrument.Timer;
|
||||||
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.Semaphore;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вторая ступень распознавания.
|
||||||
|
*
|
||||||
|
* <p>Правила и словарь разбирают подавляющее большинство случаев и стоят десятки
|
||||||
|
* микросекунд. Модель нужна там, где они бессильны: имена без русского
|
||||||
|
* словообразования и нестандартные топонимы.
|
||||||
|
*
|
||||||
|
* <p>Поэтому модель зовут не на весь текст, а только на кандидатов — цепочки из
|
||||||
|
* двух-трёх слов с заглавной буквы, которые первая ступень не покрыла. Их в обычном
|
||||||
|
* запросе единицы, и на задержку это почти не влияет. Дороже модель — тем важнее
|
||||||
|
* такая экономия: у BERT вызов стоит десятки миллисекунд, и звать его на каждый
|
||||||
|
* запрос было бы невозможно.
|
||||||
|
*
|
||||||
|
* <p>Движок один — модель BERT, размечающая имена и составляющие адреса. Настройка
|
||||||
|
* {@code pdguard.ner.engine} принимает {@code off} или {@code rubert}; ступень
|
||||||
|
* выключена, пока движок не задан.
|
||||||
|
*
|
||||||
|
* <p>Ступень выключена, пока не задан движок. Сбой ступени на первую не влияет:
|
||||||
|
* ошибка перехватывается здесь, ступень выключается насовсем, и дальше работают
|
||||||
|
* правила. Иначе одно исключение обнуляло бы маскирование целиком.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class NameCascade {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(NameCascade.class);
|
||||||
|
|
||||||
|
/** Имя метрики обращений ко второй ступени, её описание и имя метки исхода. */
|
||||||
|
private static final String NER_REQUESTS_METRIC = "pdguard.ner.requests";
|
||||||
|
private static final String NER_REQUESTS_DESCRIPTION = "Обращения, дошедшие до второй ступени";
|
||||||
|
private static final String OUTCOME_TAG = "outcome";
|
||||||
|
|
||||||
|
/** Цепочка из двух-трёх слов с заглавной буквы — то, что может оказаться ПД. */
|
||||||
|
private static final Pattern CANDIDATE = Pattern.compile(
|
||||||
|
"\\p{Lu}[\\p{L}-]+(?:\\s+\\p{Lu}[\\p{L}-]+){1,2}",
|
||||||
|
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||||
|
|
||||||
|
/** Приоритет находок второй ступени: ниже правил, у которых больше оснований. */
|
||||||
|
private static final int PRIORITY = 73;
|
||||||
|
|
||||||
|
/** Сколько знаков текста вокруг кандидата отдаётся модели как контекст. */
|
||||||
|
private static final int CONTEXT_CHARS = 60;
|
||||||
|
|
||||||
|
private final RuBertRecogniser recogniser;
|
||||||
|
private final Semaphore concurrent;
|
||||||
|
private final int maxCandidates;
|
||||||
|
private volatile boolean broken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько обращений дошло до модели, а сколько обошлось правилами. Отношение
|
||||||
|
* {@code engaged} ко всем обращениям и есть та доля, от которой зависит,
|
||||||
|
* посильна ли тяжёлая модель на боевом трафике.
|
||||||
|
*/
|
||||||
|
private final Counter engaged;
|
||||||
|
private final Counter withoutCandidates;
|
||||||
|
private final Counter busy;
|
||||||
|
private final Counter candidates;
|
||||||
|
private final Timer duration;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public NameCascade(
|
||||||
|
@Value("${pdguard.ner.engine:off}") String engine,
|
||||||
|
@Value("${pdguard.ner.model:}") String modelPath,
|
||||||
|
@Value("${pdguard.ner.max-candidates:16}") int maxCandidates,
|
||||||
|
@Value("${pdguard.ner.pool-size:16}") int poolSize,
|
||||||
|
MeterRegistry meters) {
|
||||||
|
this.maxCandidates = maxCandidates;
|
||||||
|
this.recogniser = create(engine, Optional.ofNullable(modelPath).filter(p -> !p.isBlank()));
|
||||||
|
this.concurrent = new Semaphore(Math.max(1, poolSize));
|
||||||
|
this.engaged = Counter.builder(NER_REQUESTS_METRIC)
|
||||||
|
.description(NER_REQUESTS_DESCRIPTION)
|
||||||
|
.tag(OUTCOME_TAG, "engaged").register(meters);
|
||||||
|
this.withoutCandidates = Counter.builder(NER_REQUESTS_METRIC)
|
||||||
|
.description(NER_REQUESTS_DESCRIPTION)
|
||||||
|
.tag(OUTCOME_TAG, "no_candidates").register(meters);
|
||||||
|
this.busy = Counter.builder(NER_REQUESTS_METRIC)
|
||||||
|
.description(NER_REQUESTS_DESCRIPTION)
|
||||||
|
.tag(OUTCOME_TAG, "busy").register(meters);
|
||||||
|
this.candidates = Counter.builder("pdguard.ner.candidates")
|
||||||
|
.description("Участки текста, отданные модели").register(meters);
|
||||||
|
this.duration = Timer.builder("pdguard.ner.duration")
|
||||||
|
.description("Время работы второй ступени").register(meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конструктор для тестов: метрики никуда не отдаются. */
|
||||||
|
public NameCascade(String engine, Optional<String> modelPath, int maxCandidates, int poolSize) {
|
||||||
|
this(engine, modelPath.orElse(""), maxCandidates, poolSize, new SimpleMeterRegistry());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выключенная ступень для служебных нужд — прогрева и тестов. Отдельный
|
||||||
|
* конструктор, а не обычный путь: иначе в журнале рядом с сообщением о готовности
|
||||||
|
* распознавателя появлялось бы сообщение о его выключении, и было бы непонятно,
|
||||||
|
* что в итоге работает.
|
||||||
|
*/
|
||||||
|
private NameCascade() {
|
||||||
|
this.maxCandidates = 0;
|
||||||
|
this.recogniser = null;
|
||||||
|
this.concurrent = new Semaphore(1);
|
||||||
|
MeterRegistry meters = new SimpleMeterRegistry();
|
||||||
|
this.engaged = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "engaged");
|
||||||
|
this.withoutCandidates = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "no_candidates");
|
||||||
|
this.busy = meters.counter(NER_REQUESTS_METRIC, OUTCOME_TAG, "busy");
|
||||||
|
this.candidates = meters.counter("pdguard.ner.candidates");
|
||||||
|
this.duration = Timer.builder("pdguard.ner.duration").register(meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NameCascade disabled() {
|
||||||
|
return new NameCascade();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enabled() {
|
||||||
|
return recogniser != null && !broken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет ПД, которые не нашла первая ступень. Уже принятые фрагменты не
|
||||||
|
* трогаются: модель разбирает только непокрытые участки.
|
||||||
|
*/
|
||||||
|
public List<Span> addMissedNames(String text, List<Span> accepted) {
|
||||||
|
if (!enabled()) {
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
if (!concurrent.tryAcquire()) {
|
||||||
|
// Модель занята целиком: отвечаем по правилам, а не копим очередь.
|
||||||
|
busy.increment();
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
long started = System.nanoTime();
|
||||||
|
try {
|
||||||
|
List<Span> found = new ArrayList<>(accepted);
|
||||||
|
int examined = 0;
|
||||||
|
Matcher m = CANDIDATE.matcher(text);
|
||||||
|
while (m.find() && examined < maxCandidates) {
|
||||||
|
if (coveredBy(accepted, m.start(), m.end())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
examined++;
|
||||||
|
collect(text, m.start(), m.end(), found);
|
||||||
|
}
|
||||||
|
candidates.increment(examined);
|
||||||
|
(examined > 0 ? engaged : withoutCandidates).increment();
|
||||||
|
duration.record(System.nanoTime() - started, TimeUnit.NANOSECONDS);
|
||||||
|
return found;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
broken = true;
|
||||||
|
LOG.error("Вторая ступень отключена из-за сбоя, распознавание продолжается по правилам", e);
|
||||||
|
return accepted;
|
||||||
|
} finally {
|
||||||
|
concurrent.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collect(String text, int candidateStart, int candidateEnd, List<Span> sink) {
|
||||||
|
int from = Math.max(0, candidateStart - CONTEXT_CHARS);
|
||||||
|
int to = Math.min(text.length(), candidateEnd + CONTEXT_CHARS);
|
||||||
|
for (Span span : recogniser.recognise(text, from, to, PRIORITY)) {
|
||||||
|
if (isAccepted(text, candidateStart, candidateEnd, span)) {
|
||||||
|
sink.add(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принимает находку модели, если она пересекается с кандидатом и проходит
|
||||||
|
* те же условия, что и находки правил.
|
||||||
|
*/
|
||||||
|
private static boolean isAccepted(String text, int candidateStart, int candidateEnd, Span span) {
|
||||||
|
// Берём только пересекающееся с кандидатом: контекст добавлен ради
|
||||||
|
// качества разбора, а не для расширения находки.
|
||||||
|
if (span.start() >= candidateEnd || candidateStart >= span.end()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Адресные типы принимаются на тех же условиях, что и от правил: рядом
|
||||||
|
// должны быть другие части адреса. Иначе «Спартак Москва» и «Проспект
|
||||||
|
// Вернадского» попадали бы под маску наравне с адресом клиента.
|
||||||
|
return !RuleRegistry.isAddressType(span.type())
|
||||||
|
|| RuleRegistry.hasAddressContext(text, span.start(), span.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean coveredBy(List<Span> accepted, int start, int end) {
|
||||||
|
return accepted.stream().anyMatch(span -> span.start() < end && start < span.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuBertRecogniser create(String engine, Optional<String> modelPath) {
|
||||||
|
String chosen = engine == null ? "off" : engine.toLowerCase(Locale.ROOT).strip();
|
||||||
|
if ("off".equals(chosen) || modelPath.isEmpty() || modelPath.get().isBlank()) {
|
||||||
|
LOG.info("Вторая ступень распознавания выключена");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!"rubert".equals(chosen)) {
|
||||||
|
LOG.warn("Неизвестный движок второй ступени: {}, ступень выключена", chosen);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
RuBertRecogniser created = RuBertRecogniser.load(Path.of(modelPath.get()), 1);
|
||||||
|
if (created == null) {
|
||||||
|
LOG.info("Вторая ступень распознавания выключена: распознаватель не создан");
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
void shutdown() {
|
||||||
|
if (recogniser != null) {
|
||||||
|
recogniser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Словари для распознавания ФИО.
|
||||||
|
*
|
||||||
|
* <p>Личные имена нужны, чтобы морфология фамилий не срабатывала на чём попало:
|
||||||
|
* «Тверская» по окончанию похожа на фамилию, но рядом с ней нет личного имени.
|
||||||
|
*
|
||||||
|
* <p>Список известных людей решает обратную задачу — упоминание Пушкина
|
||||||
|
* персональными данными не является. Ограничение осознанное: клиент по фамилии
|
||||||
|
* Пушкин в тексте без других ПД замаскирован не будет.
|
||||||
|
*
|
||||||
|
* <p>Базовый список собран в сборку из {@code /names/well-known.txt}. Поверх
|
||||||
|
* него можно дописать своих публичных лиц без пересборки — файл по пути
|
||||||
|
* {@code pdguard.well-known-file} (по умолчанию {@code config/well-known.txt})
|
||||||
|
* перечитывается сам при изменении, тем же приёмом, что {@code systems.json}
|
||||||
|
* в {@link ru.pdguard.config.SystemsConfig}: раз в секунду сверяется время
|
||||||
|
* изменения, содержимое читается заново только когда оно другое.
|
||||||
|
*/
|
||||||
|
public final class NameDictionary {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(NameDictionary.class);
|
||||||
|
private static final long RECHECK_MILLIS = 1000;
|
||||||
|
|
||||||
|
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
|
||||||
|
.map(Declension::withoutInflectedEnding)
|
||||||
|
.distinct()
|
||||||
|
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||||
|
.toList();
|
||||||
|
// Гласная в конце основы отбрасывается: «Набиуллина» родительный/дательный/
|
||||||
|
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
||||||
|
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
|
||||||
|
// что и для личных имён.
|
||||||
|
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream()
|
||||||
|
.map(Declension::withoutInflectedEnding)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
|
private static final AtomicReference<Path> externalFile =
|
||||||
|
new AtomicReference<>(Path.of("config/well-known.txt"));
|
||||||
|
private static final AtomicReference<Set<String>> wellKnownStems =
|
||||||
|
new AtomicReference<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||||
|
private static volatile long externalTimestamp;
|
||||||
|
private static volatile long lastCheck;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маркер организации вплотную перед именем. Слово может стоять в любом падеже,
|
||||||
|
* между ним и именем допускается «имени» или «им.» — «Премия имени Ломоносова».
|
||||||
|
*/
|
||||||
|
private static final Pattern ORGANISATION_BEFORE = Pattern.compile(
|
||||||
|
"(?iu:" + String.join("|", load("/names/organisations.txt")) + ")\\p{L}*"
|
||||||
|
+ "(?:\\W{1,3}(?iu:имени|им\\.))?\\W{0,3}$",
|
||||||
|
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||||
|
|
||||||
|
/** Сколько знаков перед именем просматривается в поисках маркера организации. */
|
||||||
|
private static final int ORGANISATION_LOOKBEHIND = 40;
|
||||||
|
|
||||||
|
/** Порядковые числительные в имени правителя: «Пётр Первый», «Екатерина Вторая». */
|
||||||
|
private static final String REGNAL_ORDINALS =
|
||||||
|
"перв|втор|трет|четв[её]рт|пят|шест|седьм|восьм|девят|десят";
|
||||||
|
|
||||||
|
/** Прозвища правителей: «Иван Грозный», «Ярослав Мудрый», «Александр Освободитель». */
|
||||||
|
private static final String REGNAL_EPITHETS =
|
||||||
|
"велик|грозн|мудр|благословен|освободител|миротворц?|тишайш|долгорук|окаянн";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имя правителя: личное имя плюс порядковое числительное или прозвище —
|
||||||
|
* «Пётр Первый», «Иван Грозный», «Екатерина Вторая», «Ярослав Мудрый».
|
||||||
|
* Задано правилом, а не перечнем: правителей много, а форма записи одна.
|
||||||
|
*/
|
||||||
|
private static final Pattern REGNAL_NAME = Pattern.compile(
|
||||||
|
"^\\p{Lu}\\p{L}+\\s+(?iu:" + REGNAL_ORDINALS + "|" + REGNAL_EPITHETS + ")\\p{L}*$",
|
||||||
|
Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE | Pattern.CANON_EQ);
|
||||||
|
|
||||||
|
/** Не более скольких падежных букв дописывается к основе имени. */
|
||||||
|
private static final int MAX_INFLECTION = 3;
|
||||||
|
|
||||||
|
/** Остатки, превращающие основу имени в фамилию или отчество: Роман → Романов. */
|
||||||
|
private static final Set<String> SURNAME_SUFFIXES = Set.of(
|
||||||
|
"ов", "ев", "ёв", "ин", "ын", "ова", "ева", "ёва", "ина", "ына",
|
||||||
|
"ович", "евич", "овна", "евна", "овы", "евы", "ины");
|
||||||
|
|
||||||
|
private static final Set<String> GIVEN_NAMES = GIVEN_NAME_STEMS.stream()
|
||||||
|
.map(stem -> stem.toLowerCase(Locale.ROOT))
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
|
private NameDictionary() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Экземпляр для Spring-бина; словарь работает через статические методы. */
|
||||||
|
public static NameDictionary create() {
|
||||||
|
return new NameDictionary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Задаёт путь к внешнему файлу денилиста. Вызывается при старте приложения
|
||||||
|
* из конфигурации Spring-бина; статические методы словаря работают без
|
||||||
|
* экземпляра, поэтому путь хранится в статическом поле.
|
||||||
|
*/
|
||||||
|
public static void configure(String wellKnownFile) {
|
||||||
|
externalFile.set(Path.of(wellKnownFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Есть ли среди слов личное имя из словаря в любом падеже.
|
||||||
|
*
|
||||||
|
* <p>Проверка множеством, а не чередованием в регулярном выражении: сто с лишним
|
||||||
|
* веток пришлось бы перебирать в каждой позиции текста, здесь же на слово
|
||||||
|
* приходится не больше четырёх обращений к хеш-таблице.
|
||||||
|
*/
|
||||||
|
public static boolean containsGivenName(String value) {
|
||||||
|
for (String word : value.split("\\P{L}+")) {
|
||||||
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
|
// Точное совпадение с основой сильнее всего: «Яков» оканчивается на «ов»,
|
||||||
|
// но это имя, а не фамилия.
|
||||||
|
if (GIVEN_NAMES.contains(lower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// По началу слова имя ищется с оглядкой на остаток: «Марина» это основа
|
||||||
|
// «марин» плюс падежное «а», а «Романов» — основа «роман» плюс фамильное
|
||||||
|
// «ов». Без этой разницы «Бизнес-центр Романов Двор» принимался бы за
|
||||||
|
// человека, а «Марина Шевченко» переставала бы им быть.
|
||||||
|
for (int length = Math.max(1, lower.length() - MAX_INFLECTION); length < lower.length(); length++) {
|
||||||
|
if (GIVEN_NAMES.contains(lower.substring(0, length))
|
||||||
|
&& !SURNAME_SUFFIXES.contains(lower.substring(length))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стоит ли перед именем слово, относящее его к организации или объекту на карте.
|
||||||
|
*
|
||||||
|
* <p>«Институт Склифосовского», «Музей Тропинина», «улица Королёва» — это имена
|
||||||
|
* в названиях, а не персональные данные. Отличие от списка известных людей в том,
|
||||||
|
* что здесь решает не само имя, а слово перед ним: клиент по фамилии Королёв
|
||||||
|
* защиту не теряет, а улица Королёва под маску не попадает.
|
||||||
|
*/
|
||||||
|
public static boolean precededByOrganisation(String text, int nameStart) {
|
||||||
|
int from = Math.max(0, nameStart - ORGANISATION_LOOKBEHIND);
|
||||||
|
return ORGANISATION_BEFORE.matcher(text.substring(from, nameStart)).find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Содержит ли текст упоминание известного человека — из сборки или дописанных
|
||||||
|
* сверху.
|
||||||
|
*
|
||||||
|
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
|
||||||
|
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
|
||||||
|
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
|
||||||
|
* префиксов у слова — не больше, чем в нём букв.
|
||||||
|
*/
|
||||||
|
public static boolean isWellKnown(String value) {
|
||||||
|
if (REGNAL_NAME.matcher(value.strip()).matches()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
refreshIfChanged();
|
||||||
|
Set<String> stems = wellKnownStems.get();
|
||||||
|
for (String word : value.split("\\P{L}+")) {
|
||||||
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
|
for (int length = lower.length(); length > 0; length--) {
|
||||||
|
if (stems.contains(lower.substring(0, length))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Путь к внешнему файлу денилиста — для тестов, чтобы не трогать {@code config/}. */
|
||||||
|
static void useExternalFile(Path path) {
|
||||||
|
externalFile.set(path);
|
||||||
|
externalTimestamp = -1;
|
||||||
|
lastCheck = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перечитать внешний файл немедленно, минуя секундный троттлинг проверки. */
|
||||||
|
static synchronized void reloadExternal() {
|
||||||
|
lastCheck = System.currentTimeMillis();
|
||||||
|
if (!Files.isReadable(externalFile.get())) {
|
||||||
|
if (wellKnownStems.get() != BUNDLED_WELL_KNOWN_STEMS) {
|
||||||
|
LOG.info("Внешний файл денилиста {} исчез, остаётся только встроенный список",
|
||||||
|
externalFile.get().toAbsolutePath());
|
||||||
|
}
|
||||||
|
wellKnownStems.set(BUNDLED_WELL_KNOWN_STEMS);
|
||||||
|
externalTimestamp = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
externalTimestamp = Files.getLastModifiedTime(externalFile.get()).toMillis();
|
||||||
|
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||||
|
for (String line : Files.readAllLines(externalFile.get(), StandardCharsets.UTF_8)) {
|
||||||
|
String trimmed = Declension.withoutInflectedEnding(line.trim());
|
||||||
|
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||||
|
merged.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wellKnownStems.set(Set.copyOf(merged));
|
||||||
|
LOG.info("Денилист дополнен из {}: {} имён сверх встроенных",
|
||||||
|
externalFile.get().toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
|
||||||
|
} catch (IOException e) {
|
||||||
|
// Битый файл не должен ронять маскирование: остаётся прежний список.
|
||||||
|
LOG.error("Не удалось прочитать {}, денилист не изменён", externalFile.get().toAbsolutePath(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void refreshIfChanged() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastCheck < RECHECK_MILLIS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCheck = now;
|
||||||
|
try {
|
||||||
|
if (!Files.isReadable(externalFile.get())) {
|
||||||
|
if (externalTimestamp != 0) {
|
||||||
|
reloadExternal();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Files.getLastModifiedTime(externalFile.get()).toMillis() != externalTimestamp) {
|
||||||
|
reloadExternal();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.debug("Не удалось проверить время изменения {}", externalFile.get(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основы сортируются от длинных к коротким: в чередовании регулярного
|
||||||
|
* выражения побеждает первая подошедшая ветка, и короткая основа не должна
|
||||||
|
* перехватывать совпадение у длинной.
|
||||||
|
*/
|
||||||
|
private static List<String> load(String resource) {
|
||||||
|
try (InputStream in = NameDictionary.class.getResourceAsStream(resource)) {
|
||||||
|
if (in == null) {
|
||||||
|
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
|
||||||
|
}
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||||
|
return reader.lines()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||||
|
.distinct()
|
||||||
|
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import ai.onnxruntime.OnnxTensor;
|
||||||
|
import ai.onnxruntime.OrtEnvironment;
|
||||||
|
import ai.onnxruntime.OrtException;
|
||||||
|
import ai.onnxruntime.OrtSession;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.LongBuffer;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Распознаватель на BERT: размечает имена и составляющие адреса за один проход.
|
||||||
|
*
|
||||||
|
* <p>В отличие от правил, он опознаёт имена без русского словообразования и
|
||||||
|
* нестандартные топонимы. Метки модели ложатся почти один в один
|
||||||
|
* на типы из технического задания: имя, отчество, фамилия, страна, регион, район,
|
||||||
|
* город, улица, дом.
|
||||||
|
*
|
||||||
|
* <p>Модель тяжёлая — сто семьдесят мегабайт и около двенадцати миллисекунд на
|
||||||
|
* вызов, — поэтому её зовут только на участках, которые не разобрала первая
|
||||||
|
* ступень. Одновременных вызовов не больше, чем задано: иначе один запрос с
|
||||||
|
* десятком кандидатов занял бы все ядра.
|
||||||
|
*/
|
||||||
|
final class RuBertRecogniser {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(RuBertRecogniser.class);
|
||||||
|
|
||||||
|
/** Предел длины входа: участки короткие, до потолка модели в 512 далеко. */
|
||||||
|
private static final int MAX_PIECES = 190;
|
||||||
|
|
||||||
|
/** Метки модели в типы ПД сервиса. Имя, отчество и фамилия — один тип. */
|
||||||
|
private static final Map<String, String> TYPES = Map.of(
|
||||||
|
"FIRST_NAME", RuleRegistry.FIO,
|
||||||
|
"MIDDLE_NAME", RuleRegistry.FIO,
|
||||||
|
"LAST_NAME", RuleRegistry.FIO,
|
||||||
|
"COUNTRY", RuleRegistry.ADDRESS_COUNTRY,
|
||||||
|
"REGION", RuleRegistry.ADDRESS_REGION,
|
||||||
|
"DISTRICT", RuleRegistry.ADDRESS_DISTRICT,
|
||||||
|
"CITY", RuleRegistry.ADDRESS_CITY,
|
||||||
|
"STREET", RuleRegistry.ADDRESS_STREET,
|
||||||
|
"HOUSE", RuleRegistry.ADDRESS_HOUSE);
|
||||||
|
|
||||||
|
private final OrtEnvironment environment;
|
||||||
|
private final OrtSession session;
|
||||||
|
private final WordPiece tokenizer;
|
||||||
|
private final String[] labels;
|
||||||
|
private final Set<String> inputNames;
|
||||||
|
|
||||||
|
private RuBertRecogniser(OrtEnvironment environment, OrtSession session,
|
||||||
|
WordPiece tokenizer, String[] labels) throws OrtException {
|
||||||
|
this.environment = environment;
|
||||||
|
this.session = session;
|
||||||
|
this.tokenizer = tokenizer;
|
||||||
|
this.labels = labels;
|
||||||
|
this.inputNames = session.getInputNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает модель из каталога с файлами {@code model_int8.onnx}, {@code vocab.txt}
|
||||||
|
* и {@code config.json}. Каталог недоступен или испорчен — вернётся {@code null},
|
||||||
|
* и сервис продолжит работать на правилах.
|
||||||
|
*/
|
||||||
|
static RuBertRecogniser load(Path directory, int threadsPerCall) {
|
||||||
|
Path model = directory.resolve("model_int8.onnx");
|
||||||
|
Path vocabulary = directory.resolve("vocab.txt");
|
||||||
|
Path config = directory.resolve("config.json");
|
||||||
|
if (!Files.isReadable(model) || !Files.isReadable(vocabulary) || !Files.isReadable(config)) {
|
||||||
|
LOG.warn("Модель BERT в {} неполна, распознаватель не создан", directory.toAbsolutePath());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
OrtEnvironment environment = OrtEnvironment.getEnvironment();
|
||||||
|
OrtSession.SessionOptions options = new OrtSession.SessionOptions();
|
||||||
|
options.setIntraOpNumThreads(threadsPerCall);
|
||||||
|
options.setInterOpNumThreads(1);
|
||||||
|
OrtSession session = environment.createSession(model.toString(), options);
|
||||||
|
RuBertRecogniser recogniser = new RuBertRecogniser(environment, session,
|
||||||
|
WordPiece.fromVocabulary(vocabulary), readLabels(config));
|
||||||
|
LOG.info("Распознаватель BERT готов, модель {}", model.toAbsolutePath());
|
||||||
|
return recogniser;
|
||||||
|
} catch (OrtException | IOException | RuntimeException e) {
|
||||||
|
LOG.error("Не удалось загрузить модель BERT из {}", directory.toAbsolutePath(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Span> recognise(String text, int from, int to, int priority) {
|
||||||
|
String region = text.substring(from, to);
|
||||||
|
List<WordPiece.Piece> pieces = tokenizer.split(region, MAX_PIECES);
|
||||||
|
if (pieces.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String[] tags = classify(pieces);
|
||||||
|
return toSpans(pieces, tags, from, priority);
|
||||||
|
} catch (OrtException e) {
|
||||||
|
throw new IllegalStateException("Сбой вычисления модели BERT", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String[] classify(List<WordPiece.Piece> pieces) throws OrtException {
|
||||||
|
int length = pieces.size() + 2;
|
||||||
|
long[] ids = new long[length];
|
||||||
|
long[] mask = new long[length];
|
||||||
|
long[] types = new long[length];
|
||||||
|
ids[0] = tokenizer.classifyId();
|
||||||
|
for (int i = 0; i < pieces.size(); i++) {
|
||||||
|
ids[i + 1] = pieces.get(i).id();
|
||||||
|
}
|
||||||
|
ids[length - 1] = tokenizer.separatorId();
|
||||||
|
java.util.Arrays.fill(mask, 1L);
|
||||||
|
|
||||||
|
long[] shape = {1, length};
|
||||||
|
Map<String, OnnxTensor> inputs = new HashMap<>();
|
||||||
|
try {
|
||||||
|
inputs.put("input_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(ids), shape));
|
||||||
|
inputs.put("attention_mask", OnnxTensor.createTensor(environment, LongBuffer.wrap(mask), shape));
|
||||||
|
if (inputNames.contains("token_type_ids")) {
|
||||||
|
inputs.put("token_type_ids", OnnxTensor.createTensor(environment, LongBuffer.wrap(types), shape));
|
||||||
|
}
|
||||||
|
inputs.keySet().retainAll(inputNames);
|
||||||
|
try (OrtSession.Result result = session.run(inputs)) {
|
||||||
|
float[][][] logits = (float[][][]) result.get(0).getValue();
|
||||||
|
String[] tags = new String[pieces.size()];
|
||||||
|
for (int i = 0; i < pieces.size(); i++) {
|
||||||
|
tags[i] = labels[argmax(logits[0][i + 1])];
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inputs.values().forEach(OnnxTensor::close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает подряд идущие подслова одной сущности в фрагменты исходного текста.
|
||||||
|
* Схема разметки различает начало, середину, конец и одиночный токен, но для
|
||||||
|
* сборки достаточно смены типа: границы участков и так проставлены по словам.
|
||||||
|
*/
|
||||||
|
private static List<Span> toSpans(List<WordPiece.Piece> pieces, String[] tags, int offset, int priority) {
|
||||||
|
List<Span> spans = new ArrayList<>();
|
||||||
|
String currentType = null;
|
||||||
|
int start = 0;
|
||||||
|
int end = 0;
|
||||||
|
for (int i = 0; i < tags.length; i++) {
|
||||||
|
String type = TYPES.get(entityOf(tags[i]));
|
||||||
|
if (type != null && type.equals(currentType)) {
|
||||||
|
end = pieces.get(i).end();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (currentType != null) {
|
||||||
|
spans.add(new Span(offset + start, offset + end, currentType, priority));
|
||||||
|
}
|
||||||
|
currentType = type;
|
||||||
|
start = pieces.get(i).start();
|
||||||
|
end = pieces.get(i).end();
|
||||||
|
}
|
||||||
|
if (currentType != null) {
|
||||||
|
spans.add(new Span(offset + start, offset + end, currentType, priority));
|
||||||
|
}
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String entityOf(String tag) {
|
||||||
|
int dash = tag.indexOf('-');
|
||||||
|
return dash < 0 ? tag : tag.substring(dash + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int argmax(float[] scores) {
|
||||||
|
int best = 0;
|
||||||
|
for (int i = 1; i < scores.length; i++) {
|
||||||
|
if (scores[i] > scores[best]) {
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] readLabels(Path config) throws IOException {
|
||||||
|
JsonNode node = new ObjectMapper().readTree(Files.readAllBytes(config)).get("id2label");
|
||||||
|
String[] labels = new String[node.size()];
|
||||||
|
for (Iterator<Map.Entry<String, JsonNode>> it = node.fields(); it.hasNext(); ) {
|
||||||
|
Map.Entry<String, JsonNode> entry = it.next();
|
||||||
|
labels[Integer.parseInt(entry.getKey())] = entry.getValue().asText();
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
try {
|
||||||
|
session.close();
|
||||||
|
} catch (OrtException e) {
|
||||||
|
LOG.debug("Не удалось закрыть сессию модели", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Одно правило детекции персональных данных.
|
||||||
|
*
|
||||||
|
* <p>Добавление нового типа ПД — это добавление одного {@code Rule} в
|
||||||
|
* {@link RuleRegistry}; менять остальной код не требуется.
|
||||||
|
*
|
||||||
|
* @param type тип ПД, который распознаёт правило
|
||||||
|
* @param pattern регулярное выражение
|
||||||
|
* @param priority приоритет при разрешении перекрытий
|
||||||
|
* @param groups номера групп, которые маскируются; {@code 0} — всё совпадение целиком.
|
||||||
|
* Несколько групп нужны, когда значение разорвано словами:
|
||||||
|
* «серия 4509 номер 123456»
|
||||||
|
* @param validator дополнительная проверка значения (контрольная сумма, диапазон дат);
|
||||||
|
* {@code null} — проверка не нужна
|
||||||
|
* @param veto шаблон окружения, при котором совпадение персональными данными не считается:
|
||||||
|
* адрес отделения банка не является ПД, хотя выглядит как адрес
|
||||||
|
* @param context шаблон окружения, который обязан присутствовать рядом. Нужен там,
|
||||||
|
* где форма совпадения сама по себе слишком общая: «Невский проспект»
|
||||||
|
* это адрес рядом с домом и индексом и просто топоним в рассказе о городе
|
||||||
|
* @param anchors строчные подстроки, одна из которых обязана встретиться в тексте.
|
||||||
|
* Проверка через {@code indexOf} на порядок дешевле запуска
|
||||||
|
* регулярного выражения и отсекает большинство правил на коротком
|
||||||
|
* запросе. Пустой список — правило запускается всегда
|
||||||
|
*/
|
||||||
|
public record Rule(String type, Pattern pattern, int priority, List<Integer> groups,
|
||||||
|
Predicate<String> validator, Pattern veto, Pattern context, List<String> anchors) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Флаги компиляции для всех правил.
|
||||||
|
*
|
||||||
|
* <p>{@code UNICODE_CHARACTER_CLASS} обязателен: без него {@code \w}, {@code \W}
|
||||||
|
* и {@code \b} в Java охватывают только латиницу, и якорные слова вроде
|
||||||
|
* «водительское удостоверение» не находятся. {@code UNICODE_CASE} делает
|
||||||
|
* {@code (?i)} корректным для кириллицы.
|
||||||
|
*/
|
||||||
|
private static final int FLAGS = Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько символов слева и справа от совпадения просматривает вето-шаблон.
|
||||||
|
*
|
||||||
|
* <p>150, не 80: на реальных адресах отделений из реестра ЦБ (регион, город,
|
||||||
|
* улица, дом — в одном предложении) расстояние от «отделение» до номера дома
|
||||||
|
* часто превышает 80 знаков за счёт длинного названия региона («Ханты-Мансийский
|
||||||
|
* автономный округ», «Кабардино-Балкарская Республика»). Найдено нагрузочным
|
||||||
|
* тестом на 60 реальных адресах из официального реестра — с окном в 80 знаков
|
||||||
|
* вето не срабатывало на части из них.
|
||||||
|
*/
|
||||||
|
public static final int VETO_LOOKBEHIND = 150;
|
||||||
|
public static final int VETO_LOOKAHEAD = 40;
|
||||||
|
|
||||||
|
/** Правило без проверок, маскируется всё совпадение. */
|
||||||
|
public static Rule of(String type, String regex, int priority) {
|
||||||
|
return new Rule(type, Pattern.compile(regex, FLAGS), priority, List.of(0), null, null, null, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Маскировать только перечисленные группы, а не всё совпадение. */
|
||||||
|
public Rule groups(Integer... indexes) {
|
||||||
|
return new Rule(type, pattern, priority, List.of(indexes), validator, veto, context, anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Принять совпадение, только если значение прошло проверку. */
|
||||||
|
public Rule validatedBy(Predicate<String> check) {
|
||||||
|
return new Rule(type, pattern, priority, groups, check, veto, context, anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запускать правило, только если в тексте есть одна из подстрок (в нижнем регистре). */
|
||||||
|
public Rule anchoredBy(String... required) {
|
||||||
|
return new Rule(type, pattern, priority, groups, validator, veto, context, List.of(required));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Есть ли в тексте хоть один из якорей правила. */
|
||||||
|
public boolean mayMatch(String lowercasedText) {
|
||||||
|
if (anchors.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (String anchor : anchors) {
|
||||||
|
if (lowercasedText.contains(anchor)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Принять совпадение, только если рядом встретилось указанное слово. */
|
||||||
|
public Rule requiringNear(String regex) {
|
||||||
|
return new Rule(type, pattern, priority, groups, validator, veto, Pattern.compile(regex, FLAGS), anchors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отбросить совпадение, если рядом встретилось указанное слово. */
|
||||||
|
public Rule vetoedBy(String regex) {
|
||||||
|
return new Rule(type, pattern, priority, groups, validator, Pattern.compile(regex, FLAGS), context, anchors);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реестр правил детекции и сам поиск ПД в тексте.
|
||||||
|
*
|
||||||
|
* <p>Правила разбиты на три уровня доверия:
|
||||||
|
* <ol>
|
||||||
|
* <li>проверяемые контрольной суммой — карта, ИНН, СНИЛС: ложных срабатываний почти нет;</li>
|
||||||
|
* <li>однозначные по формату — email, телефон;</li>
|
||||||
|
* <li>требующие якорного слова — паспорт, водительское удостоверение, CVV, адрес и прочее,
|
||||||
|
* где сама по себе последовательность знаков ни о чём не говорит.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Якорные слова распознаются без учёта регистра — флаг {@code (?iu:...)} навешен
|
||||||
|
* именно на них. На захватываемое значение регистронезависимость не распространяется:
|
||||||
|
* там, где значение опознаётся по заглавной букве, это существенно.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RuleRegistry {
|
||||||
|
|
||||||
|
public static final String EMAIL = "EMAIL";
|
||||||
|
public static final String PHONE = "PHONE";
|
||||||
|
public static final String CARD = "CARD";
|
||||||
|
public static final String INN = "INN";
|
||||||
|
public static final String SNILS = "SNILS";
|
||||||
|
public static final String PASSPORT = "PASSPORT";
|
||||||
|
public static final String PASSPORT_ISSUER = "PASSPORT_ISSUER";
|
||||||
|
public static final String PASSPORT_DATE = "PASSPORT_DATE";
|
||||||
|
public static final String DEPT_CODE = "DEPT_CODE";
|
||||||
|
public static final String DRIVER_LICENSE = "DRIVER_LICENSE";
|
||||||
|
public static final String CITIZENSHIP = "CITIZENSHIP";
|
||||||
|
public static final String BIRTH_PLACE = "BIRTH_PLACE";
|
||||||
|
public static final String BIRTH_DATE = "BIRTH_DATE";
|
||||||
|
public static final String DATE = "DATE";
|
||||||
|
public static final String CVV = "CVV";
|
||||||
|
public static final String PIN = "PIN";
|
||||||
|
public static final String CARDHOLDER = "CARDHOLDER";
|
||||||
|
public static final String ADDRESS_COUNTRY = "ADDRESS_COUNTRY";
|
||||||
|
public static final String ADDRESS_POSTCODE = "ADDRESS_POSTCODE";
|
||||||
|
public static final String ADDRESS_CITY = "ADDRESS_CITY";
|
||||||
|
public static final String ADDRESS_STREET = "ADDRESS_STREET";
|
||||||
|
public static final String ADDRESS_HOUSE = "ADDRESS_HOUSE";
|
||||||
|
public static final String ADDRESS_FLAT = "ADDRESS_FLAT";
|
||||||
|
/** Регион и район размечает только модель второй ступени: правил под них нет. */
|
||||||
|
public static final String ADDRESS_REGION = "ADDRESS_REGION";
|
||||||
|
public static final String ADDRESS_DISTRICT = "ADDRESS_DISTRICT";
|
||||||
|
public static final String FIO = "FIO";
|
||||||
|
public static final String FOREIGN_PASSPORT = "FOREIGN_PASSPORT";
|
||||||
|
public static final String MILITARY_ID = "MILITARY_ID";
|
||||||
|
public static final String BIRTH_CERTIFICATE = "BIRTH_CERTIFICATE";
|
||||||
|
public static final String MEDICAL_POLICY = "MEDICAL_POLICY";
|
||||||
|
|
||||||
|
/** Банковские реквизиты сверх платёжной карты. */
|
||||||
|
public static final String ACCOUNT_NUMBER = "ACCOUNT_NUMBER";
|
||||||
|
public static final String BIK = "BIK";
|
||||||
|
public static final String CARD_EXPIRY = "CARD_EXPIRY";
|
||||||
|
public static final String INCOME = "INCOME";
|
||||||
|
public static final String OGRN = "OGRN";
|
||||||
|
public static final String OGRNIP = "OGRNIP";
|
||||||
|
public static final String KPP = "KPP";
|
||||||
|
public static final String BIOMETRIC = "BIOMETRIC";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слово с заглавной буквы; остальные буквы любого регистра, чтобы
|
||||||
|
* «ИВАНОВ» распознавался наравне с «Иванов».
|
||||||
|
*/
|
||||||
|
private static final String CAPITALISED = "\\p{Lu}[\\p{Lu}\\p{Ll}]+";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Название улицы: от одного до трёх слов с заглавной буквы либо чисел —
|
||||||
|
* «Тверская», «Малая Никитская», «8 Марта». Ограничение по форме обязательно:
|
||||||
|
* без него правило дожёвывало строку до конца, и «Проспект Вернадского перекрыт
|
||||||
|
* до вечера» оказывался под маской целиком.
|
||||||
|
*/
|
||||||
|
private static final String STREET_NAME =
|
||||||
|
"(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)(?:\\s+(?:\\p{Lu}[\\p{L}-]+|\\d+[\\p{L}-]*)){0,2}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фамилия по словообразованию: Иванов, Ковалёва, Троицкий, Шевченко, Мкртчян.
|
||||||
|
* Хвост из двух букв покрывает падежные окончания: Ковалёв-ой, Иванов-а.
|
||||||
|
*/
|
||||||
|
private static final String SURNAME =
|
||||||
|
"\\p{Lu}[\\p{Lu}\\p{Ll}]*(?iu:ов|ев|ёв|ин|ын|ск(?:ий|ая|ого|ой|ом)|цк(?:ий|ая)"
|
||||||
|
+ "|енко|ко|ук|юк|ян|швили|дзе)\\p{L}{0,2}";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отчество: признак надёжный, ни одно другое слово так не оканчивается.
|
||||||
|
* Основы даны без падежного окончания — Иванович, Ивановича, Ивановне.
|
||||||
|
*/
|
||||||
|
private static final String PATRONYMIC =
|
||||||
|
"\\p{Lu}[\\p{Lu}\\p{Ll}]+(?iu:ович|евич|ьич|мич|нич|тич|лич|кич|бич|сич"
|
||||||
|
+ "|овн|евн|иничн|ичн)\\p{L}{0,2}";
|
||||||
|
|
||||||
|
/** Серия и номер: «4509 123456», «45 09 123456», «4509123456», «45 09 № 123456». */
|
||||||
|
private static final String SERIES_AND_NUMBER = "\\d{2}\\s?\\d{2}[\\s№N]{0,3}\\d{6}";
|
||||||
|
|
||||||
|
private static final String MONTH =
|
||||||
|
"(?iu:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\\p{L}*";
|
||||||
|
|
||||||
|
/** Числовая запись при любом порядке частей: дд.мм.гггг, мм/дд/гггг, гггг-мм-дд. */
|
||||||
|
private static final String DATE_DIGITS = "\\b\\d{1,4}[.\\-/]\\d{1,2}[.\\-/]\\d{1,4}\\b";
|
||||||
|
|
||||||
|
/** «12 мая 1985 г.» */
|
||||||
|
private static final String DATE_MONTH_WORD =
|
||||||
|
"\\b\\d{1,2}\\s+" + MONTH + "\\s+\\d{4}\\b(?:\\s*(?iu:года|г\\.|г\\b))?";
|
||||||
|
|
||||||
|
/** «двенадцатого мая тысяча девятьсот восемьдесят пятого года» */
|
||||||
|
private static final String DATE_WORDS =
|
||||||
|
"\\b(?:(?iu:двадцать|тридцать)\\s+)?"
|
||||||
|
+ "(?iu:перв|втор|треть|четв[её]рт|пят|шест|седьм|восьм|девят|десят|одиннадцат|двенадцат"
|
||||||
|
+ "|тринадцат|четырнадцат|пятнадцат|шестнадцат|семнадцат|восемнадцат|девятнадцат|двадцат|тридцат)"
|
||||||
|
+ "(?iu:ьего|ого|его)\\s+" + MONTH
|
||||||
|
+ "\\s+(?:\\d{4}|(?iu:тысяча)[\\p{L}\\s]{5,60}?)\\s*(?iu:года|год\\b|г\\.)";
|
||||||
|
|
||||||
|
/** Любая из трёх записей даты; внутри только незахватывающие группы. */
|
||||||
|
private static final String DATE_ANY = "(?:" + DATE_WORDS + "|" + DATE_MONTH_WORD + "|" + DATE_DIGITS + ")";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слова, при которых адрес принадлежит организации, а не человеку:
|
||||||
|
* адрес отделения банка персональными данными не является. Части адреса рядом:
|
||||||
|
* улица, упомянутая в рассказе о городе, адресом клиента не является — ровно
|
||||||
|
* как адрес отделения банка из технического задания. Требование стояло только
|
||||||
|
* у постфиксной формы правила, префиксная его не имела.
|
||||||
|
*/
|
||||||
|
public static final String ADDRESS_NEARBY =
|
||||||
|
"(?iu:адрес|индекс|\\bд\\.|\\bдом\\b|\\bкв\\.|\\bг\\.|\\bгород|регистрац|прожива)";
|
||||||
|
|
||||||
|
private static final Pattern ADDRESS_CONTEXT =
|
||||||
|
Pattern.compile(ADDRESS_NEARBY, Pattern.UNICODE_CHARACTER_CLASS | Pattern.UNICODE_CASE);
|
||||||
|
|
||||||
|
/** Адресные типы, которые вне адресного окружения персональными данными не являются. */
|
||||||
|
private static final java.util.Set<String> ADDRESS_TYPES = java.util.Set.of(
|
||||||
|
ADDRESS_COUNTRY, ADDRESS_REGION, ADDRESS_DISTRICT, ADDRESS_CITY,
|
||||||
|
ADDRESS_STREET, ADDRESS_HOUSE, ADDRESS_FLAT, ADDRESS_POSTCODE);
|
||||||
|
|
||||||
|
public static boolean isAddressType(String type) {
|
||||||
|
return ADDRESS_TYPES.contains(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Есть ли рядом другие части адреса. Правила проверяют это сами, а находкам
|
||||||
|
* второй ступени проверку нужно навязать снаружи: модель размечает «Москву» в
|
||||||
|
* названии клуба и «Вернадского» в названии проспекта наравне с настоящим адресом.
|
||||||
|
*/
|
||||||
|
public static boolean hasAddressContext(String text, int start, int end) {
|
||||||
|
return ADDRESS_CONTEXT.matcher(surroundings(text, start, end)).find();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String ORGANISATION_NEARBY =
|
||||||
|
"(?iu:отделени|филиал|банкомат|доп\\.?\\s?офис|офис|головн|юридическ\\p{L}*\\s+адрес)";
|
||||||
|
|
||||||
|
private static final List<Rule> RULES = List.of(
|
||||||
|
|
||||||
|
// --- Уровень 3: значение опознаётся только рядом с якорным словом ---
|
||||||
|
|
||||||
|
Rule.of(CVV, "(?iu:\\b(?:cvv2?|cvc2?|код\\s+проверки|защитный\\s+код))\\W{0,5}(\\d{3,4})\\b", 92)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("cvv", "cvc", "код проверки", "защитный код"),
|
||||||
|
|
||||||
|
Rule.of(PIN, "(?iu:\\bпин[\\s-]?кода?|\\bpin[\\s-]?code|\\bpin)\\b\\W{0,5}(\\d{4,6})\\b", 92)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("пин", "pin"),
|
||||||
|
|
||||||
|
// «паспорт 4509 123456», «паспорт гражданина РФ 45 09 123456»
|
||||||
|
Rule.of(PASSPORT, "(?iu:паспорт)\\w*(?:\\W+(?iu:гражданина\\s+РФ|РФ|России|Российской\\s+Федерации))?"
|
||||||
|
+ "\\W{0,10}(" + SERIES_AND_NUMBER + ")\\b", 90)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("паспорт"),
|
||||||
|
|
||||||
|
// «серия 4509 номер 123456», «серии 45 09 № 123456»
|
||||||
|
// Между серией и номером помещается слово: «серия 4509 номер 123456»,
|
||||||
|
// «серии 4509 за номером 123456», «серия 4509 № 123456».
|
||||||
|
Rule.of(PASSPORT, "(?iu:сери)\\w{0,3}\\W{0,5}(\\d{2}\\s?\\d{2})[^\\d]{0,20}(\\d{6})\\b", 90)
|
||||||
|
.groups(1, 2)
|
||||||
|
.anchoredBy("сери"),
|
||||||
|
|
||||||
|
Rule.of(DRIVER_LICENSE, "(?iu:водительск\\w+\\s+удостоверени\\w+|в/у|вод\\.\\s?удост\\w*|\\bВУ)\\b"
|
||||||
|
+ "\\W{0,15}(" + SERIES_AND_NUMBER + ")\\b", 89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("водительск", "в/у", "вод.", "ву "),
|
||||||
|
|
||||||
|
// --- Прочие документы, удостоверяющие личность ---
|
||||||
|
|
||||||
|
Rule.of(FOREIGN_PASSPORT, "(?iu:загранпаспорт|заграничн\\p{L}*\\s+паспорт)\\p{L}*"
|
||||||
|
+ "\\W{0,10}(\\d{2}\\s?\\d{7})\\b", 89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("загранпаспорт", "заграничн"),
|
||||||
|
|
||||||
|
Rule.of(MILITARY_ID, "(?iu:военн\\p{L}*\\s+билет)\\p{L}*"
|
||||||
|
+ "\\W{0,10}(\\p{Lu}{2}\\s?\\d{7})\\b", 89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("военн"),
|
||||||
|
|
||||||
|
Rule.of(BIRTH_CERTIFICATE, "(?iu:свидетельств\\p{L}*\\s+о\\s+рождении)"
|
||||||
|
+ "\\W{0,15}([IVXLC]{1,4}[- ]?\\p{Lu}{2}\\s?(?:№\\s?)?\\d{6})\\b", 89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("свидетельств"),
|
||||||
|
|
||||||
|
Rule.of(MEDICAL_POLICY, "(?iu:полис\\p{L}*(?:\\s+ОМС)?)\\W{0,10}(\\d{16})\\b", 89)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("полис"),
|
||||||
|
|
||||||
|
Rule.of(DEPT_CODE, "(?iu:код\\w*\\s+подразделения|к/п)\\W{0,5}(\\d{3}\\s?-?\\s?\\d{3})\\b", 88)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("подразделени", "к/п"),
|
||||||
|
|
||||||
|
// --- Банковские реквизиты сверх карты ---
|
||||||
|
|
||||||
|
// Расчётный счёт — ровно 20 цифр после якоря, группировка пробелами не важна.
|
||||||
|
Rule.of(ACCOUNT_NUMBER, "(?iu:р/с|расчетн\\w*\\s+счет|расчётн\\w*\\s+счёт|лицев\\w*\\s+счет|"
|
||||||
|
+ "лицев\\w*\\s+счёт)\\W{0,5}((?:\\d[ ]?){19}\\d)\\b", 83)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("р/с", "расчетн", "расчётн", "лицев"),
|
||||||
|
|
||||||
|
Rule.of(BIK, "(?iu:бик)\\W{0,5}(\\d{9})\\b", 83)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("бик"),
|
||||||
|
|
||||||
|
// «действительна до 09/27», «exp 09/27» — срок действия карты, не дата рождения.
|
||||||
|
Rule.of(CARD_EXPIRY, "(?iu:срок\\s+действия|действительна?\\s+до|\\bexp\\w*)\\W{0,5}"
|
||||||
|
+ "(\\d{2}\\s?/\\s?\\d{2})\\b", 83)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("срок действия", "действительн", "exp"),
|
||||||
|
|
||||||
|
// ОГРНИП раньше ОГРН: без отрицательного просмотра «ОГРНИП» частично ловился бы
|
||||||
|
// ещё и правилом ОГРН. Контрольная сумма отсекает случайные 13/15-значные
|
||||||
|
// числа рядом со словом — раньше якоря было достаточно самого по себе.
|
||||||
|
Rule.of(OGRNIP, "(?iu:огрнип)\\W{0,5}(\\d{15})\\b", 83)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::ogrnip)
|
||||||
|
.anchoredBy("огрнип"),
|
||||||
|
|
||||||
|
Rule.of(OGRN, "(?iu:огрн(?!ип))\\W{0,5}(\\d{13})\\b", 83)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::ogrn)
|
||||||
|
.anchoredBy("огрн"),
|
||||||
|
|
||||||
|
Rule.of(KPP, "(?iu:кпп)\\W{0,5}(\\d{9})\\b", 83)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("кпп"),
|
||||||
|
|
||||||
|
// Доход/зарплата: сумма с разделителями тысяч. Между якорем и суммой может
|
||||||
|
// стоять слово («доход клиента», «доход за год») — без этого якорь ловил
|
||||||
|
// бы только «доход 85000», вплотную.
|
||||||
|
Rule.of(INCOME, "(?iu:доход|заработн\\w*\\s+плат\\w*|зарплат\\w*)(?:\\s+\\p{L}+){0,3}?"
|
||||||
|
+ "\\W{0,5}(\\d{1,3}(?:[\\s.]?\\d{3})*(?:,\\d{2})?)\\s?(?iu:руб\\p{L}*|₽)?\\b", 76)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("доход", "заработн", "зарплат"),
|
||||||
|
|
||||||
|
// Биометрия — сама фраза уже говорит, что дальше персональные данные, отдельного
|
||||||
|
// значения для захвата нет: маскируется якорная фраза целиком.
|
||||||
|
Rule.of(BIOMETRIC, "(?iu:биометрическ\\w*\\s+(?:данны\\w*|образц\\w*|шаблон\\w*)"
|
||||||
|
+ "|слепок\\s+голоса|отпечаток\\s+пальца|скан\\s+лица|\\bЕБС\\b)", 81)
|
||||||
|
.anchoredBy("биометри", "слепок голоса", "отпечаток пальца", "скан лица", "ебс"),
|
||||||
|
|
||||||
|
// --- Даты с явным якорем ---
|
||||||
|
|
||||||
|
Rule.of(BIRTH_DATE, "(?iu:дат\\p{L}*\\s+рождения|дата\\s+рожд\\.)\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
.anchoredBy("рожден"),
|
||||||
|
|
||||||
|
Rule.of(BIRTH_DATE, "(?iu:родил(?:ся|ась))\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
.anchoredBy("родил"),
|
||||||
|
|
||||||
|
Rule.of(BIRTH_DATE, "(" + DATE_ANY + ")\\s*(?iu:г\\.\\s?р\\.|г/р|года\\s+рождения)", 87)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
.anchoredBy("г.р", "г/р", "года рождения"),
|
||||||
|
|
||||||
|
// «дата выдачи 12.05.2015» и «дата выдачи паспорта 12.05.2015»
|
||||||
|
Rule.of(PASSPORT_DATE, "(?iu:дат\\p{L}*\\s+выдачи)(?:\\s+\\p{L}+)?\\W{0,5}(" + DATE_ANY + ")", 87)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
.anchoredBy("выдач"),
|
||||||
|
|
||||||
|
Rule.of(CARDHOLDER, "(?iu:держател\\w*(?:\\s+карты)?|cardholder|на\\s+имя)"
|
||||||
|
+ "\\W{0,10}([A-Z]{2,20}\\s+[A-Z]{2,20})\\b", 86)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("держател", "cardholder", "на имя"),
|
||||||
|
|
||||||
|
// --- ФИО ---
|
||||||
|
|
||||||
|
// Фамилия Имя Отчество: первое слово опознаётся по словообразованию фамилии.
|
||||||
|
// Свободная тройка «любое слово с заглавной + имя + отчество» здесь
|
||||||
|
// сознательно не используется: она захватывает глагол в начале
|
||||||
|
// предложения («Пригласите Ивана Сергеевича») и заметно дороже по времени.
|
||||||
|
// Фамилии без привычного окончания — Ким, Цой — ловятся по ролевому слову.
|
||||||
|
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 79),
|
||||||
|
|
||||||
|
// Имя Отчество Фамилия — второй распространённый порядок слов.
|
||||||
|
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\s+" + SURNAME + "\\b", 79),
|
||||||
|
|
||||||
|
// Иванов И.И. и И.И. Иванов
|
||||||
|
Rule.of(FIO, "\\b" + SURNAME + "\\s+\\p{Lu}\\.\\s?\\p{Lu}\\.", 79),
|
||||||
|
Rule.of(FIO, "\\b\\p{Lu}\\.\\s?\\p{Lu}\\.\\s?" + SURNAME + "\\b", 79),
|
||||||
|
|
||||||
|
// Имя Отчество без фамилии
|
||||||
|
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + PATRONYMIC + "\\b", 77),
|
||||||
|
|
||||||
|
// «ФИО: иванов иван иванович» — явный якорь снимает требование к регистру
|
||||||
|
Rule.of(FIO, "(?iu:\\bФИО|\\bф\\.\\s?и\\.\\s?о\\.|\\bна\\s+имя)"
|
||||||
|
+ "(?:\\s+\\p{L}+)?\\W{0,5}(\\p{L}{2,}(?:\\s+\\p{L}{2,}){0,2})\\b", 77)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("фио", "ф.и.о", "на имя"),
|
||||||
|
|
||||||
|
// «клиент Иванов Иван», «плательщик Петрова»
|
||||||
|
Rule.of(FIO, "(?iu:\\bклиент|\\bзаказчик|\\bпациент|\\bсотрудник|\\bвладел|\\bплательщик"
|
||||||
|
+ "|\\bполучател|\\bабонент|\\bв\\s+лице|\\bпредставител|\\bпоручител"
|
||||||
|
+ "|\\bсозаёмщик|\\bсозаемщик|\\bзаёмщик|\\bзаемщик|\\bзаявител|\\bдоверител"
|
||||||
|
+ "|\\bвкладчик|\\bответственн|\\bконтактное\\s+лицо|\\bисполнител|\\bдержател)\\p{L}*"
|
||||||
|
+ "\\W{0,5}(\\p{Lu}\\p{Ll}+(?:\\s+\\p{Lu}\\p{Ll}+){0,2})\\b", 77)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("клиент", "заказчик", "пациент", "сотрудник", "владел", "плательщик",
|
||||||
|
"получател", "абонент", "в лице", "представител", "поручител", "заёмщик",
|
||||||
|
"заемщик", "заявител", "доверител", "вкладчик", "ответственн",
|
||||||
|
"контактное лицо", "исполнител", "держател"),
|
||||||
|
|
||||||
|
// Фамилия рядом с личным именем из словаря: без словаря правило ловило бы
|
||||||
|
// «Тверская улица» и тому подобное. Имя проверяется по множеству уже
|
||||||
|
// после совпадения — чередование из ста веток в шаблоне обходится дорого.
|
||||||
|
// Самое слабое основание среди правил ФИО — ни ролевого слова, ни явного
|
||||||
|
// якоря, — поэтому именно здесь нужно вето на адресный контекст: «Великие
|
||||||
|
// Луки» (реальный город) распознаётся как имя «Лука» в падеже плюс
|
||||||
|
// случайное слово, «Богдана Хмельницкого» — улица в честь исторической
|
||||||
|
// фигуры. Найдено на реальных адресах отделений из реестра ЦБ.
|
||||||
|
Rule.of(FIO, "\\b" + SURNAME + "\\s+" + CAPITALISED + "\\b", 74)
|
||||||
|
.validatedBy(NameDictionary::containsGivenName)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY),
|
||||||
|
Rule.of(FIO, "\\b" + CAPITALISED + "\\s+" + SURNAME + "\\b", 74)
|
||||||
|
.validatedBy(NameDictionary::containsGivenName)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY),
|
||||||
|
|
||||||
|
// --- Уровень 1: подтверждается контрольной суммой ---
|
||||||
|
|
||||||
|
Rule.of(CARD, "\\b\\d(?:[ -]?\\d){11,18}\\b", 85)
|
||||||
|
.validatedBy(Validators::luhn),
|
||||||
|
|
||||||
|
Rule.of(INN, "(?iu)\\bИНН\\b\\D{0,10}(\\d{12}|\\d{10})\\b", 84)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("инн"),
|
||||||
|
|
||||||
|
Rule.of(SNILS, "(?iu)(?:\\bСНИЛС\\b\\D{0,10})?(\\d{3}[ -]\\d{3}[ -]\\d{3}[ -]\\d{2})\\b", 84)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(Validators::snils),
|
||||||
|
|
||||||
|
// --- Уровень 2: формат однозначен сам по себе ---
|
||||||
|
|
||||||
|
Rule.of(PHONE, "(?:\\+7|\\b8)[ ()-]{0,3}\\d{3}[ ()-]{0,3}\\d{3}[ -]{0,2}\\d{2}[ -]{0,2}\\d{2}\\b", 82),
|
||||||
|
|
||||||
|
Rule.of(EMAIL, "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}\\b", 80)
|
||||||
|
.anchoredBy("@"),
|
||||||
|
|
||||||
|
// --- Уровень 3: свободный текст после якорного слова ---
|
||||||
|
|
||||||
|
// «выдан ОУФМС России по г. Москве 12.05.2015» — дата в состав органа не входит,
|
||||||
|
// её забирает отдельное правило. Приоритет выше городского, иначе от органа
|
||||||
|
// осталась бы замаскированной только его часть.
|
||||||
|
Rule.of(PASSPORT_ISSUER, "(?iu:выдан)[\\p{L}]*\\W{0,3}([^,;\\n]{3,90}?)"
|
||||||
|
+ "(?=\\s*\\d{1,2}[.\\-/]\\d{1,2}[.\\-/]\\d{2,4}|[,;\\n]|\\s*$)", 78)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("выдан"),
|
||||||
|
|
||||||
|
Rule.of(BIRTH_PLACE, "(?iu:мест\\w*\\s+рождения)\\W{0,5}([^,;\\n]{3,60}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("рождения"),
|
||||||
|
|
||||||
|
Rule.of(BIRTH_PLACE, "(?iu:родил(?:ся|ась))[^,;\\n]{0,40}?\\s+в\\s+"
|
||||||
|
+ "([^,;\\n]{3,40}?)(?=\\s*[,;\\n]|\\s*$)", 76)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("родил"),
|
||||||
|
|
||||||
|
Rule.of(CITIZENSHIP, "(?iu:гражданств)\\w*\\W{0,5}"
|
||||||
|
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("гражданств"),
|
||||||
|
|
||||||
|
Rule.of(CITIZENSHIP, "(?iu:граждан(?:ин|ка|ина|ки))\\b\\s+"
|
||||||
|
+ "((?iu:рф|россии|российской\\s+федерации|республики\\s+\\p{L}+)|\\p{Lu}\\p{Ll}+)\\b", 75)
|
||||||
|
.groups(1)
|
||||||
|
.anchoredBy("граждан"),
|
||||||
|
|
||||||
|
// --- Адрес: каждая составляющая настраивается отдельно ---
|
||||||
|
|
||||||
|
Rule.of(ADDRESS_POSTCODE, "(?iu:индекс)\\W{0,5}(\\d{6})\\b", 74)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("индекс"),
|
||||||
|
|
||||||
|
Rule.of(ADDRESS_POSTCODE,
|
||||||
|
"\\b(\\d{6})(?=\\s*,?\\s*(?iu:г\\.|город|обл\\.|область|респ|край))", 74)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY),
|
||||||
|
|
||||||
|
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
|
||||||
|
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
|
||||||
|
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
|
||||||
|
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
|
||||||
|
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
|
||||||
|
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
|
||||||
|
.groups(1)
|
||||||
|
.validatedBy(ToponymDictionary::isKnownSettlement)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
|
||||||
|
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
|
||||||
|
|
||||||
|
Rule.of(ADDRESS_STREET,
|
||||||
|
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
|
||||||
|
+ "|\\bш\\.|\\bшоссе|\\bб-р|\\bбульвар\\p{L}*|\\bнаб\\.|\\bнабережн\\p{L}*)"
|
||||||
|
+ "\\W{0,3}(" + STREET_NAME + ")", 73)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.requiringNear(ADDRESS_NEARBY)
|
||||||
|
.anchoredBy("ул", "просп", "пр-т", "пер.", "шоссе", "ш.", "бульвар", "б-р", "наб"),
|
||||||
|
|
||||||
|
// «Невский пр-т» — указатель после названия. Форма слишком общая, поэтому
|
||||||
|
// принимается только рядом с другими частями адреса: иначе под маску попал бы
|
||||||
|
// любой рассказ про Невский проспект.
|
||||||
|
Rule.of(ADDRESS_STREET, "\\b(\\p{Lu}[\\p{L}-]{2,30})\\s+"
|
||||||
|
+ "(?iu:пр-т|проспект|улиц\\p{L}*|шоссе|бульвар|переул\\p{L}*|набережн\\p{L}*)\\b", 73)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.requiringNear(ADDRESS_NEARBY)
|
||||||
|
.anchoredBy("пр-т", "проспект", "улиц", "шоссе", "бульвар", "переул", "набережн"),
|
||||||
|
|
||||||
|
Rule.of(ADDRESS_HOUSE,
|
||||||
|
"(?iu:\\bд\\.|\\bдом)\\s?(\\d+\\p{L}?(?:\\s?(?iu:к\\.|корп\\.?|стр\\.)\\s?\\d+)?)\\b", 72)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("д.", "дом"),
|
||||||
|
|
||||||
|
Rule.of(ADDRESS_FLAT, "(?iu:\\bкв\\.|\\bквартир\\p{L}*)\\s?(\\d+\\p{L}?)\\b", 72)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("кв"),
|
||||||
|
|
||||||
|
Rule.of(ADDRESS_COUNTRY,
|
||||||
|
"(?iu:стран\\p{L}*(?:\\s+(?:регистрации|проживания|гражданства))?)"
|
||||||
|
+ "\\W{0,5}(\\p{Lu}[\\p{L}-]{2,30})\\b", 71)
|
||||||
|
.groups(1)
|
||||||
|
.vetoedBy(ORGANISATION_NEARBY)
|
||||||
|
.anchoredBy("стран"),
|
||||||
|
|
||||||
|
// --- Значения без якоря: принимаются только вместе с другими ПД ---
|
||||||
|
|
||||||
|
// ИНН физлица без якорного слова — только с верной контрольной суммой.
|
||||||
|
Rule.of(INN, "\\b\\d{12}\\b", 62)
|
||||||
|
.validatedBy(Validators::inn),
|
||||||
|
|
||||||
|
// Дата без якорного слова персональными данными сама по себе не является:
|
||||||
|
// маскируется, только если в тексте есть ПД другого типа.
|
||||||
|
Rule.of(DATE, DATE_ANY, 58)
|
||||||
|
.validatedBy(Validators::date)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Все типы ПД, которые умеет распознавать сервис. */
|
||||||
|
public List<String> knownTypes() {
|
||||||
|
return RULES.stream().map(Rule::type).distinct().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Находит все фрагменты ПД, разрешённые политикой системы.
|
||||||
|
* Перекрытия здесь не разрешаются — это делает вызывающая сторона.
|
||||||
|
*/
|
||||||
|
public List<Span> detect(String text, SystemPolicy policy) {
|
||||||
|
List<Span> found = new ArrayList<>();
|
||||||
|
String lowercased = text.toLowerCase(Locale.ROOT);
|
||||||
|
for (Rule rule : RULES) {
|
||||||
|
if (!policy.allows(rule.type()) || !rule.mayMatch(lowercased)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
collect(rule, text, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void collect(Rule rule, String text, List<Span> sink) {
|
||||||
|
Matcher m = rule.pattern().matcher(text);
|
||||||
|
while (m.find()) {
|
||||||
|
for (int group : rule.groups()) {
|
||||||
|
int start = m.start(group);
|
||||||
|
int end = m.end(group);
|
||||||
|
if (isValidGroup(rule, text, start, end)) {
|
||||||
|
sink.add(new Span(start, end, rule.type(), rule.priority()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверяет, что фрагмент группы проходит все условия правила: границы, валидатор, veto и контекст. */
|
||||||
|
private static boolean isValidGroup(Rule rule, String text, int start, int end) {
|
||||||
|
if (start < 0 || end <= start) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (rule.validator() != null && !rule.validator().test(text.substring(start, end))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String surroundings = surroundings(text, start, end);
|
||||||
|
if (rule.veto() != null && rule.veto().matcher(surroundings).find()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rule.context() == null || rule.context().matcher(surroundings).find();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String surroundings(String text, int start, int end) {
|
||||||
|
int from = Math.max(0, start - Rule.VETO_LOOKBEHIND);
|
||||||
|
int to = Math.min(text.length(), end + Rule.VETO_LOOKAHEAD);
|
||||||
|
return text.substring(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найденный фрагмент персональных данных в исходном тексте.
|
||||||
|
*
|
||||||
|
* @param start индекс первого символа (включительно)
|
||||||
|
* @param end индекс за последним символом (исключительно)
|
||||||
|
* @param type тип ПД, например {@code CARD} или {@code EMAIL}
|
||||||
|
* @param priority приоритет при разрешении перекрытий: больше — важнее
|
||||||
|
*/
|
||||||
|
public record Span(int start, int end, String type, int priority) {
|
||||||
|
|
||||||
|
public Span {
|
||||||
|
if (start < 0 || end <= start) {
|
||||||
|
throw new IllegalArgumentException("Некорректные границы фрагмента: " + start + ".." + end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int length() {
|
||||||
|
return end - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean overlaps(Span other) {
|
||||||
|
return start < other.end && other.start < end;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Словарь населённых пунктов России — проверка того, что значение, пойманное
|
||||||
|
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
|
||||||
|
* село, посёлок или другой населённый пункт, а не на произвольное слово с
|
||||||
|
* заглавной буквы после якоря.
|
||||||
|
*
|
||||||
|
* <p>Не только официальные города (~1100 по классификатору): перепись
|
||||||
|
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
|
||||||
|
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
|
||||||
|
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
|
||||||
|
* а не этот словарь — он только подтверждает, что название реальное.
|
||||||
|
*
|
||||||
|
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
|
||||||
|
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
|
||||||
|
* морфологии, как и у известных людей в {@link NameDictionary}.
|
||||||
|
*/
|
||||||
|
public final class ToponymDictionary {
|
||||||
|
|
||||||
|
private static final Set<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream()
|
||||||
|
.map(Declension::withoutInflectedEnding)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
|
private ToponymDictionary() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Похоже ли значение на название населённого пункта из словаря в любом
|
||||||
|
* падеже.
|
||||||
|
*
|
||||||
|
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
|
||||||
|
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
|
||||||
|
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
|
||||||
|
* них сравнение идёт по основе без неё — так же, как с личными именами
|
||||||
|
* в {@link NameDictionary}.
|
||||||
|
*
|
||||||
|
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
|
||||||
|
* основ по значению: перебор списка на каждое совпадение правила был бы
|
||||||
|
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
|
||||||
|
*/
|
||||||
|
public static boolean isKnownSettlement(String value) {
|
||||||
|
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||||
|
for (int length = lower.length(); length > 0; length--) {
|
||||||
|
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> load(String resource) {
|
||||||
|
try (InputStream in = ToponymDictionary.class.getResourceAsStream(resource)) {
|
||||||
|
if (in == null) {
|
||||||
|
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
|
||||||
|
}
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||||
|
return reader.lines()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверки контрольных сумм. Отсекают случайные числовые последовательности,
|
||||||
|
* которые по форме похожи на ПД, но ими не являются.
|
||||||
|
*/
|
||||||
|
public final class Validators {
|
||||||
|
|
||||||
|
private static final int[] INN_10 = {2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||||
|
private static final int[] INN_12_A = {7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||||
|
private static final int[] INN_12_B = {3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8};
|
||||||
|
|
||||||
|
private Validators() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Алгоритм Луна: номер платёжной карты, 13–19 цифр. */
|
||||||
|
public static boolean luhn(String value) {
|
||||||
|
int sum = 0;
|
||||||
|
int digits = 0;
|
||||||
|
boolean doubled = false;
|
||||||
|
for (int i = value.length() - 1; i >= 0; i--) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (!Character.isDigit(c)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int d = c - '0';
|
||||||
|
digits++;
|
||||||
|
if (doubled) {
|
||||||
|
d *= 2;
|
||||||
|
if (d > 9) {
|
||||||
|
d -= 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum += d;
|
||||||
|
doubled = !doubled;
|
||||||
|
}
|
||||||
|
return digits >= 13 && digits <= 19 && sum % 10 == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контрольная сумма ИНН: 10 знаков у юрлица, 12 у физлица. */
|
||||||
|
public static boolean inn(String value) {
|
||||||
|
int[] d = digits(value);
|
||||||
|
if (d.length == 10) {
|
||||||
|
return d[9] == checksum(d, INN_10);
|
||||||
|
}
|
||||||
|
if (d.length == 12) {
|
||||||
|
return d[10] == checksum(d, INN_12_A) && d[11] == checksum(d, INN_12_B);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контрольная сумма СНИЛС: 11 знаков, последние два — контрольные. */
|
||||||
|
public static boolean snils(String value) {
|
||||||
|
int[] d = digits(value);
|
||||||
|
if (d.length != 11) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int sum = 0;
|
||||||
|
for (int i = 0; i < 9; i++) {
|
||||||
|
sum += d[i] * (9 - i);
|
||||||
|
}
|
||||||
|
int control = snilsControl(sum);
|
||||||
|
return control == d[9] * 10 + d[10];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контрольное число СНИЛС по сумме первых девяти цифр. */
|
||||||
|
private static int snilsControl(int sum) {
|
||||||
|
if (sum < 100) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
if (sum == 100 || sum == 101) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return sum % 101 % 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контрольная сумма ОГРН: первые 12 цифр по модулю 11, младший разряд — 13-я цифра. */
|
||||||
|
public static boolean ogrn(String value) {
|
||||||
|
int[] d = digits(value);
|
||||||
|
return d.length == 13 && d[12] == modReduce(d, 12, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контрольная сумма ОГРНИП: первые 14 цифр по модулю 13, младший разряд — 15-я цифра. */
|
||||||
|
public static boolean ogrnip(String value) {
|
||||||
|
int[] d = digits(value);
|
||||||
|
return d.length == 15 && d[14] == modReduce(d, 14, 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Остаток от деления первых {@code count} цифр как одного числа на {@code divisor},
|
||||||
|
* взятый по младшему разряду. Числовое накопление по цифрам, а не парсинг строки
|
||||||
|
* в {@code long}: у ОГРНИП 14 цифр — на грани переполнения {@code int}, и это тот же
|
||||||
|
* приём, что уже применяется к самой длинной последовательности в {@link #luhn}.
|
||||||
|
*/
|
||||||
|
private static int modReduce(int[] d, int count, int divisor) {
|
||||||
|
long remainder = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
remainder = (remainder * 10 + d[i]) % divisor;
|
||||||
|
}
|
||||||
|
return (int) (remainder % 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дата в числовой записи при любом порядке частей: {@code 12.05.1985},
|
||||||
|
* {@code 05/12/1985}, {@code 1985-05-12}. Отсекает похожие по форме
|
||||||
|
* последовательности вроде {@code 192.168.1}.
|
||||||
|
*/
|
||||||
|
public static boolean date(String value) {
|
||||||
|
// Запись с названием месяца словом в дополнительной проверке не нуждается:
|
||||||
|
// «мая» само по себе однозначно указывает на дату.
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
if (Character.isLetter(value.charAt(i))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String[] parts = value.split("[.\\-/]");
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int[] n = new int[3];
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
if (parts[i].isEmpty() || parts[i].length() > 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
n[i] = Integer.parseInt(parts[i]);
|
||||||
|
}
|
||||||
|
for (int y = 0; y < 3; y++) {
|
||||||
|
if (parts[y].length() == 4) {
|
||||||
|
return n[y] >= 1900 && n[y] <= 2100 && dayAndMonth(n[(y + 1) % 3], n[(y + 2) % 3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Год записан двумя цифрами: достаточно, чтобы день и месяц нашлись в любой паре.
|
||||||
|
return dayAndMonth(n[0], n[1]) || dayAndMonth(n[1], n[2]) || dayAndMonth(n[0], n[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пара чисел похожа на «день и месяц» в любом порядке. */
|
||||||
|
private static boolean dayAndMonth(int a, int b) {
|
||||||
|
return (a >= 1 && a <= 31 && b >= 1 && b <= 12) || (b >= 1 && b <= 31 && a >= 1 && a <= 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int checksum(int[] d, int[] weights) {
|
||||||
|
int sum = 0;
|
||||||
|
for (int i = 0; i < weights.length; i++) {
|
||||||
|
sum += d[i] * weights[i];
|
||||||
|
}
|
||||||
|
return sum % 11 % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] digits(String value) {
|
||||||
|
int[] out = new int[value.length()];
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (Character.isDigit(c)) {
|
||||||
|
out[n++] = c - '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int[] trimmed = new int[n];
|
||||||
|
System.arraycopy(out, 0, trimmed, 0, n);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разбиение текста на подслова так, как это делает токенизатор BERT.
|
||||||
|
*
|
||||||
|
* <p>Своя реализация вместо готовой библиотеки: единственная альтернатива на Java
|
||||||
|
* подтягивает нативные библиотеки во время работы, а контейнер должен подниматься
|
||||||
|
* без обращений в сеть. Правила здесь простые и целиком описаны форматом словаря:
|
||||||
|
* разбить по пробелам и знакам препинания, затем каждое слово — жадно по самой
|
||||||
|
* длинной подходящей записи словаря, продолжения помечаются префиксом «##».
|
||||||
|
*
|
||||||
|
* <p>Для каждого подслова сохраняются границы в исходном тексте: без них разметку
|
||||||
|
* модели не перенести обратно на строку.
|
||||||
|
*/
|
||||||
|
final class WordPiece {
|
||||||
|
|
||||||
|
/** Слово длиннее этого целиком заменяется на «неизвестно» — правило BERT. */
|
||||||
|
private static final int MAX_WORD_CHARS = 100;
|
||||||
|
|
||||||
|
private static final String CONTINUATION = "##";
|
||||||
|
|
||||||
|
/** Подслово и его границы в исходном тексте. */
|
||||||
|
record Piece(int id, int start, int end) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Map<String, Integer> vocabulary;
|
||||||
|
private final int unknownId;
|
||||||
|
private final int classifyId;
|
||||||
|
private final int separatorId;
|
||||||
|
|
||||||
|
private WordPiece(Map<String, Integer> vocabulary) {
|
||||||
|
this.vocabulary = vocabulary;
|
||||||
|
this.unknownId = required(vocabulary, "[UNK]");
|
||||||
|
this.classifyId = required(vocabulary, "[CLS]");
|
||||||
|
this.separatorId = required(vocabulary, "[SEP]");
|
||||||
|
}
|
||||||
|
|
||||||
|
static WordPiece fromVocabulary(Path vocabularyFile) throws IOException {
|
||||||
|
Map<String, Integer> vocabulary = HashMap.newHashMap(140_000);
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(Files.newInputStream(vocabularyFile), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
int index = 0;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
vocabulary.putIfAbsent(line.strip(), index++);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new WordPiece(vocabulary);
|
||||||
|
}
|
||||||
|
|
||||||
|
int classifyId() {
|
||||||
|
return classifyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
int separatorId() {
|
||||||
|
return separatorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подслова текста в порядке следования; служебные токены сюда не входят. */
|
||||||
|
List<Piece> split(String text, int maxPieces) {
|
||||||
|
List<Piece> pieces = new ArrayList<>();
|
||||||
|
for (int[] word : words(text)) {
|
||||||
|
if (pieces.size() >= maxPieces) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
splitWord(text, word[0], word[1], pieces, maxPieces);
|
||||||
|
}
|
||||||
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Границы слов: разделителями считаются пробельные символы и знаки препинания,
|
||||||
|
* причём знак препинания сам становится отдельным словом.
|
||||||
|
*/
|
||||||
|
private static List<int[]> words(String text) {
|
||||||
|
List<int[]> result = new ArrayList<>();
|
||||||
|
int start = -1;
|
||||||
|
for (int i = 0; i < text.length(); i++) {
|
||||||
|
char c = text.charAt(i);
|
||||||
|
boolean separator = Character.isWhitespace(c) || isPunctuation(c);
|
||||||
|
if (separator) {
|
||||||
|
if (start >= 0) {
|
||||||
|
result.add(new int[]{start, i});
|
||||||
|
start = -1;
|
||||||
|
}
|
||||||
|
if (isPunctuation(c)) {
|
||||||
|
result.add(new int[]{i, i + 1});
|
||||||
|
}
|
||||||
|
} else if (start < 0) {
|
||||||
|
start = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start >= 0) {
|
||||||
|
result.add(new int[]{start, text.length()});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPunctuation(char c) {
|
||||||
|
if (Character.isLetterOrDigit(c)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !Character.isWhitespace(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void splitWord(String text, int from, int to, List<Piece> sink, int maxPieces) {
|
||||||
|
if (to - from > MAX_WORD_CHARS) {
|
||||||
|
sink.add(new Piece(unknownId, from, to));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int cursor = from;
|
||||||
|
List<Piece> ofThisWord = new ArrayList<>();
|
||||||
|
while (cursor < to) {
|
||||||
|
int end = to;
|
||||||
|
Integer id = null;
|
||||||
|
while (end > cursor) {
|
||||||
|
String candidate = text.substring(cursor, end);
|
||||||
|
String lookup = cursor == from ? candidate : CONTINUATION + candidate;
|
||||||
|
id = vocabulary.get(lookup);
|
||||||
|
if (id != null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
if (id == null) {
|
||||||
|
// Ни одна часть слова не нашлась — слово целиком неизвестно.
|
||||||
|
sink.add(new Piece(unknownId, from, to));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ofThisWord.add(new Piece(id, cursor, end));
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
for (Piece piece : ofThisWord) {
|
||||||
|
if (sink.size() >= maxPieces) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sink.add(piece);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int required(Map<String, Integer> vocabulary, String token) {
|
||||||
|
Integer id = vocabulary.get(token);
|
||||||
|
if (id == null) {
|
||||||
|
throw new IllegalStateException("В словаре нет служебного токена " + token);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние одной операции маскирования.
|
||||||
|
*
|
||||||
|
* <p>Одинаковые значения в пределах запроса получают одинаковую замену: если
|
||||||
|
* клиент упомянут дважды, в тексте дважды окажется {@code [FIO_1]}, и смысл
|
||||||
|
* запроса для модели сохранится.
|
||||||
|
*
|
||||||
|
* <p>Экземпляр живёт в рамках одного вызова и между потоками не разделяется.
|
||||||
|
*/
|
||||||
|
public final class MaskContext {
|
||||||
|
|
||||||
|
/** Разделитель ключа; в названии типа ПД этот знак не встречается. */
|
||||||
|
private static final char SEPARATOR = '#';
|
||||||
|
|
||||||
|
private final Map<String, String> assigned = new HashMap<>();
|
||||||
|
private final Map<String, Integer> counters = new HashMap<>();
|
||||||
|
private final Map<String, String> restorations = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Замена для значения; при повторе возвращается ранее выданная.
|
||||||
|
*
|
||||||
|
* @param factory получает тип ПД и порядковый номер значения этого типа
|
||||||
|
*/
|
||||||
|
public String resolve(String type, String value, BiFunction<String, Integer, String> factory) {
|
||||||
|
return assigned.computeIfAbsent(type + SEPARATOR + value, key -> {
|
||||||
|
String replacement = factory.apply(type, counters.merge(type, 1, Integer::sum));
|
||||||
|
restorations.put(replacement, value);
|
||||||
|
return replacement;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чем заменять обратно: подстановка к исходному значению. Нужно там, где текст
|
||||||
|
* возвращается не целиком, а изменённым — например, в ответе языковой модели.
|
||||||
|
*/
|
||||||
|
public Map<String, String> restorations() {
|
||||||
|
return Map.copyOf(restorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
/** Чем заменяется найденное значение. Выбирается настройками системы-потребителя. */
|
||||||
|
public enum MaskMode {
|
||||||
|
|
||||||
|
/** Звёздочки с сохранением длины и разделителей: {@code 45** ****56}. */
|
||||||
|
MASK,
|
||||||
|
|
||||||
|
/** Порядковый токен: {@code [FIO_1]}. Компактно и однозначно обратимо. */
|
||||||
|
TOKEN,
|
||||||
|
|
||||||
|
/** Правдоподобная подстановка: вместо настоящего имени — вымышленное. */
|
||||||
|
SYNTHETIC
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Превращает найденное значение в замену согласно настройкам системы.
|
||||||
|
*
|
||||||
|
* <p>Тип, для которого вид маски не задан, скрывается звёздочками целиком —
|
||||||
|
* безопасное поведение по умолчанию для вновь добавленных правил.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class Masker {
|
||||||
|
|
||||||
|
private static final UnaryOperator<String> EDGES = v -> Strategies.keepEdges(v, 2, 2);
|
||||||
|
private static final UnaryOperator<String> SHORT_SERIES = v -> Strategies.keepEdges(v, 0, 2);
|
||||||
|
|
||||||
|
private static final Map<String, UnaryOperator<String>> BY_TYPE = Map.ofEntries(
|
||||||
|
Map.entry(RuleRegistry.EMAIL, Strategies::email),
|
||||||
|
Map.entry(RuleRegistry.PHONE, EDGES),
|
||||||
|
Map.entry(RuleRegistry.CARD, EDGES),
|
||||||
|
Map.entry(RuleRegistry.INN, EDGES),
|
||||||
|
Map.entry(RuleRegistry.SNILS, EDGES),
|
||||||
|
Map.entry(RuleRegistry.PASSPORT, EDGES),
|
||||||
|
Map.entry(RuleRegistry.DRIVER_LICENSE, EDGES),
|
||||||
|
Map.entry(RuleRegistry.DEPT_CODE, EDGES),
|
||||||
|
// У этих документов серия короткая — две цифры или две буквы. Оставь мы
|
||||||
|
// первые два знака, серия оказалась бы открыта целиком, поэтому видны
|
||||||
|
// только последние. У паспорта РФ и водительского удостоверения серия
|
||||||
|
// из четырёх знаков, там открывается половина.
|
||||||
|
Map.entry(RuleRegistry.FOREIGN_PASSPORT, SHORT_SERIES),
|
||||||
|
Map.entry(RuleRegistry.MILITARY_ID, SHORT_SERIES),
|
||||||
|
Map.entry(RuleRegistry.BIRTH_CERTIFICATE, SHORT_SERIES),
|
||||||
|
Map.entry(RuleRegistry.MEDICAL_POLICY, EDGES),
|
||||||
|
Map.entry(RuleRegistry.CARDHOLDER, Strategies::initials),
|
||||||
|
Map.entry(RuleRegistry.FIO, Strategies::initials),
|
||||||
|
|
||||||
|
// Код проверки и пин-код не показываем даже частично: у них слишком
|
||||||
|
// мало знаков, чтобы открывать хотя бы один.
|
||||||
|
Map.entry(RuleRegistry.CVV, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.PIN, Strategies::stars),
|
||||||
|
|
||||||
|
Map.entry(RuleRegistry.PASSPORT_ISSUER, Strategies::stars),
|
||||||
|
|
||||||
|
// У дат сохраняем разделители: модель видит, что это дата, но не какая.
|
||||||
|
Map.entry(RuleRegistry.BIRTH_DATE, Strategies::starsKeepingPunctuation),
|
||||||
|
Map.entry(RuleRegistry.PASSPORT_DATE, Strategies::starsKeepingPunctuation),
|
||||||
|
Map.entry(RuleRegistry.DATE, Strategies::starsKeepingPunctuation),
|
||||||
|
|
||||||
|
Map.entry(RuleRegistry.ADDRESS_COUNTRY, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.ADDRESS_POSTCODE, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.ADDRESS_CITY, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.ADDRESS_STREET, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.ADDRESS_HOUSE, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.ADDRESS_FLAT, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.ADDRESS_REGION, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.ADDRESS_DISTRICT, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.BIRTH_PLACE, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.CITIZENSHIP, Strategies::stars),
|
||||||
|
|
||||||
|
Map.entry(RuleRegistry.ACCOUNT_NUMBER, EDGES),
|
||||||
|
Map.entry(RuleRegistry.OGRN, EDGES),
|
||||||
|
Map.entry(RuleRegistry.OGRNIP, EDGES),
|
||||||
|
Map.entry(RuleRegistry.KPP, EDGES),
|
||||||
|
// Срок действия карты — разделитель виден, сам месяц/год нет.
|
||||||
|
Map.entry(RuleRegistry.CARD_EXPIRY, Strategies::starsKeepingPunctuation),
|
||||||
|
Map.entry(RuleRegistry.BIK, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.INCOME, Strategies::stars),
|
||||||
|
Map.entry(RuleRegistry.BIOMETRIC, Strategies::stars)
|
||||||
|
);
|
||||||
|
|
||||||
|
public String mask(String type, String value, MaskMode mode, MaskContext context) {
|
||||||
|
return switch (mode) {
|
||||||
|
case MASK -> BY_TYPE.getOrDefault(type, Strategies::stars).apply(value);
|
||||||
|
case TOKEN -> context.resolve(type, value, (t, n) -> "[" + t + "_" + n + "]");
|
||||||
|
case SYNTHETIC -> context.resolve(type, value, (t, n) -> Synthetic.forType(t, value, n));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Способы преобразования найденного значения в маску.
|
||||||
|
*
|
||||||
|
* <p>Все стратегии сохраняют длину и разделители исходного значения: так
|
||||||
|
* замаскированный текст остаётся читаемым для LLM и минимально отличается
|
||||||
|
* от эталона при посимвольном сравнении.
|
||||||
|
*/
|
||||||
|
public final class Strategies {
|
||||||
|
|
||||||
|
private static final char MASK = '*';
|
||||||
|
|
||||||
|
private Strategies() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каждый непробельный символ заменяется на «*». */
|
||||||
|
public static String stars(String value) {
|
||||||
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
sb.append(Character.isWhitespace(c) ? c : MASK);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрывает буквы и цифры, оставляя разделители: {@code 12.05.1985} → {@code **.**.****},
|
||||||
|
* {@code 12 мая 1985} → {@code ** *** ****}. Форма записи остаётся видна модели,
|
||||||
|
* само значение — нет.
|
||||||
|
*/
|
||||||
|
public static String starsKeepingPunctuation(String value) {
|
||||||
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
sb.append(Character.isLetterOrDigit(c) ? MASK : c);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оставляет первые и последние значащие символы, остальные скрывает,
|
||||||
|
* разделители сохраняет: {@code 4509 123456} → {@code 45** ****56}.
|
||||||
|
*/
|
||||||
|
public static String keepEdges(String value, int head, int tail) {
|
||||||
|
int significant = 0;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
if (Character.isLetterOrDigit(value.charAt(i))) {
|
||||||
|
significant++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (significant <= head + tail) {
|
||||||
|
return stars(value);
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
|
int seen = 0;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (!Character.isLetterOrDigit(c)) {
|
||||||
|
sb.append(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean visible = seen < head || seen >= significant - tail;
|
||||||
|
sb.append(visible ? c : MASK);
|
||||||
|
seen++;
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ФИО превращается в инициалы: {@code Иванов Иван Иванович} → {@code И. И. И.} */
|
||||||
|
public static String initials(String value) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
boolean wordStart = true;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (Character.isLetter(c)) {
|
||||||
|
if (wordStart) {
|
||||||
|
if (!sb.isEmpty()) {
|
||||||
|
sb.append(' ');
|
||||||
|
}
|
||||||
|
sb.append(Character.toUpperCase(c)).append('.');
|
||||||
|
wordStart = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wordStart = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.isEmpty() ? stars(value) : sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адрес почты: видны первая буква имени ящика, первая буква домена и зона.
|
||||||
|
* {@code ivan.petrov@mail.ru} → {@code i**********@m***.ru}
|
||||||
|
*/
|
||||||
|
public static String email(String value) {
|
||||||
|
int at = value.lastIndexOf('@');
|
||||||
|
if (at <= 0 || at == value.length() - 1) {
|
||||||
|
return stars(value);
|
||||||
|
}
|
||||||
|
String local = value.substring(0, at);
|
||||||
|
String domain = value.substring(at + 1);
|
||||||
|
int dot = domain.lastIndexOf('.');
|
||||||
|
if (dot <= 0) {
|
||||||
|
return hideTail(local) + '@' + hideTail(domain);
|
||||||
|
}
|
||||||
|
return hideTail(local) + '@' + hideTail(domain.substring(0, dot)) + domain.substring(dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hideTail(String part) {
|
||||||
|
if (part.length() <= 1) {
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
return part.charAt(0) + String.valueOf(MASK).repeat(part.length() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package ru.pdguard.mask;
|
||||||
|
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правдоподобные подставные значения вместо настоящих.
|
||||||
|
*
|
||||||
|
* <p>Модель получает текст, который выглядит естественно, и качество ответа
|
||||||
|
* страдает меньше, чем от звёздочек. Значения детерминированы: одно и то же
|
||||||
|
* исходное значение всегда даёт одну и ту же подстановку.
|
||||||
|
*/
|
||||||
|
final class Synthetic {
|
||||||
|
|
||||||
|
private static final String[] SURNAMES =
|
||||||
|
{"Лаврентьев", "Мещеряков", "Тихомиров", "Ясенев", "Бурмистров", "Кольцов"};
|
||||||
|
private static final String[] NAMES = {"Артём", "Никита", "Глеб", "Тимур", "Марк", "Лев"};
|
||||||
|
private static final String[] PATRONYMICS =
|
||||||
|
{"Артёмович", "Никитич", "Глебович", "Тимурович", "Маркович", "Львович"};
|
||||||
|
private static final String[] DOMAINS = {"example.com", "example.org", "example.net"};
|
||||||
|
|
||||||
|
private Synthetic() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String forType(String type, String value, int ordinal) {
|
||||||
|
int seed = value.hashCode() & Integer.MAX_VALUE;
|
||||||
|
return switch (type) {
|
||||||
|
case RuleRegistry.FIO -> pick(SURNAMES, seed) + " " + pick(NAMES, seed >> 3)
|
||||||
|
+ " " + pick(PATRONYMICS, seed >> 6);
|
||||||
|
case RuleRegistry.CARDHOLDER -> "IVAN PETROV";
|
||||||
|
case RuleRegistry.EMAIL -> "user" + ordinal + "@" + pick(DOMAINS, seed);
|
||||||
|
case RuleRegistry.PHONE -> "+7 9" + digits(seed, 2) + " " + digits(seed >> 4, 3)
|
||||||
|
+ "-" + digits(seed >> 8, 2) + "-" + digits(seed >> 12, 2);
|
||||||
|
case RuleRegistry.CARD -> luhnCard(seed);
|
||||||
|
case RuleRegistry.PASSPORT, RuleRegistry.DRIVER_LICENSE, RuleRegistry.FOREIGN_PASSPORT,
|
||||||
|
RuleRegistry.MILITARY_ID -> digits(seed, 4) + " " + digits(seed >> 6, 6);
|
||||||
|
case RuleRegistry.INN -> digits(seed, 12);
|
||||||
|
case RuleRegistry.MEDICAL_POLICY -> digits(seed, 16);
|
||||||
|
case RuleRegistry.SNILS -> digits(seed, 3) + "-" + digits(seed >> 4, 3)
|
||||||
|
+ "-" + digits(seed >> 8, 3) + " " + digits(seed >> 12, 2);
|
||||||
|
case RuleRegistry.BIRTH_DATE, RuleRegistry.PASSPORT_DATE, RuleRegistry.DATE -> syntheticDate(seed);
|
||||||
|
case RuleRegistry.ADDRESS_CITY -> "Зареченск";
|
||||||
|
case RuleRegistry.ADDRESS_STREET -> "Сосновая";
|
||||||
|
case RuleRegistry.ADDRESS_HOUSE -> String.valueOf(1 + Math.floorMod(seed, 90));
|
||||||
|
case RuleRegistry.ADDRESS_FLAT -> String.valueOf(1 + Math.floorMod(seed, 200));
|
||||||
|
case RuleRegistry.ADDRESS_POSTCODE -> digits(seed, 6);
|
||||||
|
case RuleRegistry.ADDRESS_COUNTRY -> "Заречье";
|
||||||
|
case RuleRegistry.ADDRESS_REGION -> "Заречная область";
|
||||||
|
case RuleRegistry.ADDRESS_DISTRICT -> "Сосновый район";
|
||||||
|
case RuleRegistry.CVV -> digits(seed, 3);
|
||||||
|
case RuleRegistry.PIN -> digits(seed, 4);
|
||||||
|
// Для остальных типов правдоподобной замены нет — отдаём токен.
|
||||||
|
default -> "[" + type + "_" + ordinal + "]";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String pick(String[] options, int seed) {
|
||||||
|
return options[Math.floorMod(seed, options.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String syntheticDate(int seed) {
|
||||||
|
int day = 1 + Math.floorMod(seed, 28);
|
||||||
|
int month = 1 + Math.floorMod(seed >> 5, 12);
|
||||||
|
int year = 1960 + Math.floorMod(seed >> 9, 45);
|
||||||
|
return String.format("%02d.%02d.%d", day, month, year);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String digits(int seed, int count) {
|
||||||
|
StringBuilder sb = new StringBuilder(count);
|
||||||
|
int value = Math.abs(seed);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
sb.append((char) ('0' + Math.floorMod(value, 10)));
|
||||||
|
value = value / 10 + (i + 1) * 7;
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Номер карты, проходящий проверку алгоритмом Луна: подстановка должна выглядеть настоящей. */
|
||||||
|
private static String luhnCard(int seed) {
|
||||||
|
StringBuilder body = new StringBuilder("4").append(digits(seed, 14));
|
||||||
|
int sum = 0;
|
||||||
|
boolean doubled = true;
|
||||||
|
for (int i = body.length() - 1; i >= 0; i--) {
|
||||||
|
int d = body.charAt(i) - '0';
|
||||||
|
if (doubled) {
|
||||||
|
d *= 2;
|
||||||
|
if (d > 9) {
|
||||||
|
d -= 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sum += d;
|
||||||
|
doubled = !doubled;
|
||||||
|
}
|
||||||
|
body.append((10 - sum % 10) % 10);
|
||||||
|
return body.substring(0, 4) + " " + body.substring(4, 8) + " "
|
||||||
|
+ body.substring(8, 12) + " " + body.substring(12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
address: 0.0.0.0
|
||||||
|
max-http-request-header-size: 16KB
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: pd-guard-spring
|
||||||
|
jackson:
|
||||||
|
default-property-inclusion: non_null
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
timeout: 200ms
|
||||||
|
connect-timeout: 200ms
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,metrics,prometheus
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
probes:
|
||||||
|
enabled: true
|
||||||
|
prometheus:
|
||||||
|
metrics:
|
||||||
|
export:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Список систем-потребителей. Файла нет — работают настройки по умолчанию.
|
||||||
|
pdguard:
|
||||||
|
systems-file: config/systems.json
|
||||||
|
store:
|
||||||
|
backend: memory
|
||||||
|
max-chars: 134217728
|
||||||
|
ttl-minutes: 30
|
||||||
|
min-concurrent: 8
|
||||||
|
max-concurrent: 2000
|
||||||
|
target-latency-ms: 200
|
||||||
|
warmup-iterations: 2000
|
||||||
|
ner:
|
||||||
|
engine: off
|
||||||
|
model: models/rubert-ner
|
||||||
|
max-candidates: 16
|
||||||
|
pool-size: 16
|
||||||
|
llm:
|
||||||
|
url:
|
||||||
|
api-key:
|
||||||
|
model: gpt-4o-mini
|
||||||
|
timeout-seconds: 20
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Основы русских личных имён. Правило дописывает до трёх строчных букв,
|
||||||
|
# поэтому падежные формы (Ивану, Иваном, Ивана) покрываются основой.
|
||||||
|
# Строка — одна основа; строки с # игнорируются.
|
||||||
|
Александр
|
||||||
|
Алексей
|
||||||
|
Анатолий
|
||||||
|
Андрей
|
||||||
|
Антон
|
||||||
|
Аркадий
|
||||||
|
Арсений
|
||||||
|
Артём
|
||||||
|
Артем
|
||||||
|
Артур
|
||||||
|
Богдан
|
||||||
|
Борис
|
||||||
|
Вадим
|
||||||
|
Валентин
|
||||||
|
Валерий
|
||||||
|
Василий
|
||||||
|
Виктор
|
||||||
|
Виталий
|
||||||
|
Владимир
|
||||||
|
Владислав
|
||||||
|
Вячеслав
|
||||||
|
Геннадий
|
||||||
|
Георгий
|
||||||
|
Герман
|
||||||
|
Глеб
|
||||||
|
Григорий
|
||||||
|
Даниил
|
||||||
|
Данил
|
||||||
|
Денис
|
||||||
|
Дмитрий
|
||||||
|
Евгений
|
||||||
|
Егор
|
||||||
|
Иван
|
||||||
|
Игорь
|
||||||
|
Илья
|
||||||
|
Кирилл
|
||||||
|
Константин
|
||||||
|
Леонид
|
||||||
|
Максим
|
||||||
|
Марк
|
||||||
|
Матвей
|
||||||
|
Михаил
|
||||||
|
Никита
|
||||||
|
Николай
|
||||||
|
Олег
|
||||||
|
Павел
|
||||||
|
Пётр
|
||||||
|
Петр
|
||||||
|
Роман
|
||||||
|
Руслан
|
||||||
|
Семён
|
||||||
|
Семен
|
||||||
|
Сергей
|
||||||
|
Станислав
|
||||||
|
Степан
|
||||||
|
Тимофей
|
||||||
|
Тимур
|
||||||
|
Фёдор
|
||||||
|
Федор
|
||||||
|
Эдуард
|
||||||
|
Юрий
|
||||||
|
Ярослав
|
||||||
|
Алёна
|
||||||
|
Алена
|
||||||
|
Алина
|
||||||
|
Алла
|
||||||
|
Анастасия
|
||||||
|
Ангелина
|
||||||
|
Анна
|
||||||
|
Антонина
|
||||||
|
Валентина
|
||||||
|
Валерия
|
||||||
|
Варвара
|
||||||
|
Вероника
|
||||||
|
Виктория
|
||||||
|
Галина
|
||||||
|
Дарья
|
||||||
|
Диана
|
||||||
|
Евгения
|
||||||
|
Екатерина
|
||||||
|
Елена
|
||||||
|
Елизавета
|
||||||
|
Жанна
|
||||||
|
Зинаида
|
||||||
|
Инна
|
||||||
|
Ирина
|
||||||
|
Карина
|
||||||
|
Кристина
|
||||||
|
Ксения
|
||||||
|
Лариса
|
||||||
|
Лидия
|
||||||
|
Любовь
|
||||||
|
Людмила
|
||||||
|
Маргарита
|
||||||
|
Марина
|
||||||
|
Мария
|
||||||
|
Надежда
|
||||||
|
Наталья
|
||||||
|
Наталия
|
||||||
|
Нина
|
||||||
|
Оксана
|
||||||
|
Ольга
|
||||||
|
Полина
|
||||||
|
Раиса
|
||||||
|
Регина
|
||||||
|
Светлана
|
||||||
|
София
|
||||||
|
Софья
|
||||||
|
Тамара
|
||||||
|
Татьяна
|
||||||
|
Ульяна
|
||||||
|
Юлия
|
||||||
|
Лев
|
||||||
|
Яков
|
||||||
|
Ян
|
||||||
|
Захар
|
||||||
|
Тарас
|
||||||
|
Савва
|
||||||
|
Мирон
|
||||||
|
Демид
|
||||||
|
Клим
|
||||||
|
Влас
|
||||||
|
Родион
|
||||||
|
Святослав
|
||||||
|
Всеволод
|
||||||
|
Игнат
|
||||||
|
Филипп
|
||||||
|
Лука
|
||||||
|
Назар
|
||||||
|
Платон
|
||||||
|
Прохор
|
||||||
|
Трофим
|
||||||
|
Фома
|
||||||
|
Эмиль
|
||||||
|
Юлиан
|
||||||
|
Тихон
|
||||||
|
Гавриил
|
||||||
|
Давид
|
||||||
|
Марат
|
||||||
|
Рустам
|
||||||
|
Яна
|
||||||
|
Алиса
|
||||||
|
Василиса
|
||||||
|
Агата
|
||||||
|
Злата
|
||||||
|
Милана
|
||||||
|
Дарина
|
||||||
|
Есения
|
||||||
|
Таисия
|
||||||
|
Инга
|
||||||
|
Вера
|
||||||
|
Эмма
|
||||||
|
Нелли
|
||||||
|
Алевтина
|
||||||
|
Клавдия
|
||||||
|
Лилия
|
||||||
|
Римма
|
||||||
|
Элина
|
||||||
|
Ева
|
||||||
|
Аделина
|
||||||
|
Амина
|
||||||
|
Динара
|
||||||
|
Лейла
|
||||||
|
Сабина
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Слова, после которых идущее следом имя принадлежит организации, учреждению или
|
||||||
|
# объекту на карте, а не человеку: «Институт Склифосовского», «Музей Тропинина»,
|
||||||
|
# «улица Королёва», «Премия имени Ломоносова».
|
||||||
|
#
|
||||||
|
# Здесь не названия, а маркеры. Слово засчитывается только вплотную перед именем,
|
||||||
|
# поэтому «Больница приняла Иванова Ивана» под правило не попадает.
|
||||||
|
#
|
||||||
|
# «ИП» сюда сознательно не входит: имя индивидуального предпринимателя —
|
||||||
|
# это персональные данные.
|
||||||
|
институт
|
||||||
|
университет
|
||||||
|
академия
|
||||||
|
школа
|
||||||
|
гимназия
|
||||||
|
лицей
|
||||||
|
училище
|
||||||
|
колледж
|
||||||
|
музей
|
||||||
|
театр
|
||||||
|
галерея
|
||||||
|
библиотека
|
||||||
|
филармония
|
||||||
|
консерватория
|
||||||
|
больница
|
||||||
|
поликлиника
|
||||||
|
клиника
|
||||||
|
госпиталь
|
||||||
|
диспансер
|
||||||
|
санаторий
|
||||||
|
фонд
|
||||||
|
премия
|
||||||
|
стипендия
|
||||||
|
стадион
|
||||||
|
клуб
|
||||||
|
общество
|
||||||
|
союз
|
||||||
|
ассоциация
|
||||||
|
федерация
|
||||||
|
комитет
|
||||||
|
министерство
|
||||||
|
ведомство
|
||||||
|
департамент
|
||||||
|
управление
|
||||||
|
агентство
|
||||||
|
бюро
|
||||||
|
корпорация
|
||||||
|
холдинг
|
||||||
|
компания
|
||||||
|
завод
|
||||||
|
комбинат
|
||||||
|
фабрика
|
||||||
|
верфь
|
||||||
|
аэропорт
|
||||||
|
вокзал
|
||||||
|
станция
|
||||||
|
порт
|
||||||
|
парк
|
||||||
|
сквер
|
||||||
|
площадь
|
||||||
|
проспект
|
||||||
|
улица
|
||||||
|
переулок
|
||||||
|
бульвар
|
||||||
|
шоссе
|
||||||
|
набережная
|
||||||
|
проезд
|
||||||
|
тупик
|
||||||
|
мост
|
||||||
|
тоннель
|
||||||
|
храм
|
||||||
|
собор
|
||||||
|
монастырь
|
||||||
|
часовня
|
||||||
|
кладбище
|
||||||
|
мемориал
|
||||||
|
памятник
|
||||||
|
монумент
|
||||||
|
центр
|
||||||
|
имени
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
|||||||
|
# Известные исторические и культурные фигуры. Упоминание такого имени
|
||||||
|
# персональными данными не является — если рядом нет ПД другого типа.
|
||||||
|
# Сравнение идёт по началу слова, поэтому падежи покрываются основой.
|
||||||
|
Пушкин
|
||||||
|
Лермонтов
|
||||||
|
Толстой
|
||||||
|
Достоевский
|
||||||
|
Гоголь
|
||||||
|
Чехов
|
||||||
|
Тургенев
|
||||||
|
Некрасов
|
||||||
|
Есенин
|
||||||
|
Маяковский
|
||||||
|
Ахматова
|
||||||
|
Цветаева
|
||||||
|
Булгаков
|
||||||
|
Пастернак
|
||||||
|
Чайковский
|
||||||
|
Ломоносов
|
||||||
|
Менделеев
|
||||||
|
Гагарин
|
||||||
|
Королёв
|
||||||
|
Суворов
|
||||||
|
Кутузов
|
||||||
|
Шекспир
|
||||||
|
Эйнштейн
|
||||||
|
Ньютон
|
||||||
|
Моцарт
|
||||||
|
Бетховен
|
||||||
|
Рахманинов
|
||||||
|
Репин
|
||||||
|
Айвазовский
|
||||||
|
Циолковский
|
||||||
|
Онегин
|
||||||
|
Печорин
|
||||||
|
Раскольников
|
||||||
|
Обломов
|
||||||
|
Чичиков
|
||||||
|
Базаров
|
||||||
|
Болконский
|
||||||
|
Каренин
|
||||||
|
Чацкий
|
||||||
|
Мцыри
|
||||||
|
Хлестаков
|
||||||
|
Митрофанушка
|
||||||
|
|
||||||
|
# Действующие публичные фигуры — упоминание в новостном/служебном контексте
|
||||||
|
# («Президент подписал закон», «ЦБ во главе с Набиуллиной повысил ставку»)
|
||||||
|
# персональными данными клиента не является.
|
||||||
|
Путин
|
||||||
|
Мишустин
|
||||||
|
Набиуллина
|
||||||
|
Греф
|
||||||
|
Костин
|
||||||
|
Тиньков
|
||||||
|
Дуров
|
||||||
|
Мордашов
|
||||||
|
Потанин
|
||||||
|
Дерипаска
|
||||||
|
Абрамович
|
||||||
|
Усманов
|
||||||
|
|
||||||
|
# Фамилии, в честь которых чаще всего называют улицы в России (Росреестр,
|
||||||
|
# Яндекс.Исследования). Упоминание «улица Ленина» само по себе не задевает
|
||||||
|
# распознавание ФИО — для него нужны два слова, — но составные названия
|
||||||
|
# («Феликса Дзержинского», «Александра Матросова») попадают под то же
|
||||||
|
# правило, что и «Богдана Хмельницкого»: в честь человека, а не клиент.
|
||||||
|
Ленин
|
||||||
|
Киров
|
||||||
|
Свердлов
|
||||||
|
Дзержинский
|
||||||
|
Фрунзе
|
||||||
|
Куйбышев
|
||||||
|
Ворошилов
|
||||||
|
Будённый
|
||||||
|
Буденный
|
||||||
|
Чапаев
|
||||||
|
Жуков
|
||||||
|
Чкалов
|
||||||
|
Терешкова
|
||||||
|
Мичурин
|
||||||
|
Шевченко
|
||||||
|
Орджоникидзе
|
||||||
|
Калинин
|
||||||
|
Энгельс
|
||||||
|
Маркс
|
||||||
|
Островский
|
||||||
|
Матросов
|
||||||
|
Волошина
|
||||||
|
Сусанин
|
||||||
|
Димитров
|
||||||
|
Донской
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"default": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": true,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": ["*"],
|
||||||
|
"requireCompanion": ["CVV", "PIN", "DATE"]
|
||||||
|
},
|
||||||
|
"crm": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "TOKEN",
|
||||||
|
"types": ["FIO", "PHONE", "EMAIL", "ADDRESS_CITY", "ADDRESS_STREET", "ADDRESS_HOUSE", "ADDRESS_FLAT"]
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "SYNTHETIC",
|
||||||
|
"types": ["*"]
|
||||||
|
},
|
||||||
|
"legacy-billing": {
|
||||||
|
"enabled": false,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
/** Банковские реквизиты сверх платёжной карты: счёт, БИК, ОГРН(ИП), КПП, доход, биометрия. */
|
||||||
|
class BankTypesTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private void assertHidden(String text, String secret) {
|
||||||
|
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Банковские реквизиты маскируются рядом с данными человека. Сами по себе они
|
||||||
|
* опознают организацию или счёт, а не клиента, и в перечне типов из задания их
|
||||||
|
* нет — поэтому они переведены в {@code requireCompanion}, как пин-код и дата.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void masksAccountNumberNextToPersonalData() {
|
||||||
|
assertHidden("Клиент Иванов Иван Иванович, расчётный счёт 40702810500000001234",
|
||||||
|
"40702810500000001234");
|
||||||
|
assertHidden("Иванов И.И., р/с 4070 2810 5000 0000 1234", "4070 2810 5000 0000 1234");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksBikNextToPersonalData() {
|
||||||
|
assertHidden("Перевод Иванову Ивану Ивановичу, БИК 044525593 банка-получателя", "044525593");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsBankDetailsWithoutAnyPersonalData() {
|
||||||
|
for (String text : new String[]{
|
||||||
|
"Расчётный счёт 40702810500000001234 открыт вчера",
|
||||||
|
"БИК 044525593 банка-получателя",
|
||||||
|
"ОГРН 1027700132195 организации",
|
||||||
|
"КПП 770101001 указан в реквизитах"}) {
|
||||||
|
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"реквизиты без человека персональными данными не являются");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCardExpiryButNotCardNumber() {
|
||||||
|
String masked = pipeline.process(
|
||||||
|
"Карта 4111 1111 1111 1111, срок действия 09/27", "expiry-1", SystemPolicy.DEFAULT);
|
||||||
|
assertFalse(masked.contains("09/27"), masked);
|
||||||
|
assertEquals("Карта 41** **** **** **11, срок действия **/**", masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksOgrnAndOgrnipDifferently() {
|
||||||
|
assertHidden("Директор Иванов И.И., ОГРН 1027700132195 организации", "1027700132195");
|
||||||
|
assertHidden("ИП Иванов Иван Иванович, ОГРНИП 304500116000157", "304500116000157");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ОГРНИП (15 цифр) не должен наполовину ловиться правилом ОГРН (13 цифр). */
|
||||||
|
@Test
|
||||||
|
void ogrnDoesNotSwallowOgrnip() {
|
||||||
|
String masked = pipeline.process("ИП Иванов Иван Иванович, ОГРНИП 304500116000157",
|
||||||
|
"ogrnip-1", SystemPolicy.DEFAULT);
|
||||||
|
assertFalse(masked.contains("304500116000157"), masked);
|
||||||
|
assertFalse(masked.matches(".*\\d{15}.*"), "осталась незамаскированная часть номера: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контрольная сумма отсекает случайное 13-значное число рядом со словом «ОГРН».
|
||||||
|
* Число подобрано так, чтобы не проходить заодно и Луна — иначе оно всё равно
|
||||||
|
* маскировалось бы, но уже как номер карты, и тест ничего бы не показывал.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void doesNotMaskOgrnWithBrokenChecksum() {
|
||||||
|
String text = "ОГРН 1027700132190 организации";
|
||||||
|
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"число с неверной контрольной суммой не является настоящим ОГРН");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Та же проверка для ОГРНИП — случайное 15-значное число рядом со словом. */
|
||||||
|
@Test
|
||||||
|
void doesNotMaskOgrnipWithBrokenChecksum() {
|
||||||
|
String text = "ОГРНИП 304500116000150 предпринимателя";
|
||||||
|
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"число с неверной контрольной суммой не является настоящим ОГРНИП");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksKppNextToPersonalData() {
|
||||||
|
assertHidden("Заявитель Иванов И.И., КПП 770101001 указан в реквизитах", "770101001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksIncomeNextToPersonalData() {
|
||||||
|
assertHidden("Иванов Иван Иванович, доход 85 000 руб. в месяц", "85 000");
|
||||||
|
assertHidden("Иванову И.И. начислена заработная плата 120000 в месяц", "120000");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сумма заработка без человека — статистика или описание продукта. Опознать по
|
||||||
|
* ней никого нельзя, а для прокси к языковой модели вымаранное число означает,
|
||||||
|
* что вопрос про среднюю зарплату по отрасли отвечать уже не на чем.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void keepsIncomeWithoutAnyPersonalData() {
|
||||||
|
for (String text : new String[]{
|
||||||
|
"По данным Росстата доход домохозяйств вырос до 74 500 руб",
|
||||||
|
"Зарплатный проект: зарплата 80 000 руб перечисляется на счёт"}) {
|
||||||
|
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"сумма заработка без человека персональными данными не является");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksBiometricMentionNextToPersonalData() {
|
||||||
|
assertHidden("Клиент Иванов Иван Иванович сдал биометрические данные", "биометрические данные");
|
||||||
|
assertHidden("Для Иванова И.И. оформлен слепок голоса", "слепок голоса");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Биометрии в тексте не бывает: это шаблон в базе, и правило маскирует само
|
||||||
|
* упоминание — слово, а не данные. Без человека рядом такая замена скрывает
|
||||||
|
* ноль сведений и разрушает смысл фразы.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void keepsBiometricMentionWithoutAnyPersonalData() {
|
||||||
|
for (String text : new String[]{
|
||||||
|
"Банк внедрил биометрические данные в обслуживание клиентов",
|
||||||
|
"Сдать биометрию можно через ЕБС в любом отделении"}) {
|
||||||
|
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"упоминание биометрии без человека персональными данными не является");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Два несамостоятельных типа рядом не заверяют друг друга: сочетание даты и
|
||||||
|
* ОГРН самостоятельным не становится, человека в таком тексте нет.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void twoCompanionTypesDoNotVouchForEachOther() {
|
||||||
|
String text = "Оплата 01.02.2025, ОГРН 1027700132195";
|
||||||
|
assertEquals(text, pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT),
|
||||||
|
"спутники заверили друг друга в отсутствие настоящих ПД");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется
|
||||||
|
* и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest}
|
||||||
|
* (те же строки, перемешанные и склеенные в большой текст).
|
||||||
|
*/
|
||||||
|
final class BenchmarkFixtures {
|
||||||
|
|
||||||
|
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
||||||
|
|
||||||
|
/** Размеченный пример: чистый текст и эталонные фрагменты. */
|
||||||
|
record Sample(String text, List<Span> gold) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private BenchmarkFixtures() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
|
||||||
|
static List<Sample> load(String resource) {
|
||||||
|
List<Sample> samples = new ArrayList<>();
|
||||||
|
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
|
||||||
|
BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||||
|
samples.add(parse(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Не удалось прочитать " + resource, e);
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
|
||||||
|
static Sample parse(String line) {
|
||||||
|
StringBuilder text = new StringBuilder(line.length());
|
||||||
|
List<Span> gold = new ArrayList<>();
|
||||||
|
Matcher m = MARKUP.matcher(line);
|
||||||
|
int cursor = 0;
|
||||||
|
while (m.find()) {
|
||||||
|
text.append(line, cursor, m.start());
|
||||||
|
int start = text.length();
|
||||||
|
text.append(m.group(2));
|
||||||
|
gold.add(new Span(start, text.length(), m.group(1), 0));
|
||||||
|
cursor = m.end();
|
||||||
|
}
|
||||||
|
text.append(line, cursor, line.length());
|
||||||
|
return new Sample(text.toString(), gold);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Замер качества детекции на размеченных наборах.
|
||||||
|
*
|
||||||
|
* <p>Наборов два. {@code benchmark.txt} использовался при отладке правил, поэтому
|
||||||
|
* его оценка завышена и годится только как защита от ухудшений.
|
||||||
|
* {@code benchmark-holdout.txt} составлен независимо и на нём правила не
|
||||||
|
* настраивались — именно он показывает настоящее качество.
|
||||||
|
*
|
||||||
|
* <p>Метрики посимвольные: так они не зависят от того, где именно правило
|
||||||
|
* поставило границу совпадения, и напрямую соотносятся с посимвольным
|
||||||
|
* сравнением замаскированного текста с эталоном.
|
||||||
|
*
|
||||||
|
* <p>Отдельно считается строка «любой тип»: для защиты важно, что знаки скрыты,
|
||||||
|
* а расхождение в названии типа (скажем, место рождения против города) на
|
||||||
|
* качество маскирования не влияет.
|
||||||
|
*/
|
||||||
|
class BenchmarkTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вторая ступень для замера. Модели нет — прогон идёт на одних правилах, и это
|
||||||
|
* видно по заголовку отчёта. Путь подменяется свойством {@code -Dbench.model=...}.
|
||||||
|
*/
|
||||||
|
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
||||||
|
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
||||||
|
|
||||||
|
/** Итог замера по одному набору. */
|
||||||
|
private record Result(double fioF1, double overallPrecision, double overallRecall,
|
||||||
|
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Накопитель посимвольных совпадений по одному типу. */
|
||||||
|
private static final class Score {
|
||||||
|
private int truePositive;
|
||||||
|
private int falsePositive;
|
||||||
|
private int falseNegative;
|
||||||
|
|
||||||
|
private int gold() {
|
||||||
|
return truePositive + falseNegative;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double precision() {
|
||||||
|
int found = truePositive + falsePositive;
|
||||||
|
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double recall() {
|
||||||
|
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double f1() {
|
||||||
|
double p = precision();
|
||||||
|
double r = recall();
|
||||||
|
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Набор, на котором правила отлаживались. Пороги здесь высокие: любое падение
|
||||||
|
* означает, что сломалось то, что раньше работало.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnTuningSet() {
|
||||||
|
Result result = measure("/benchmark.txt", "набор отладки");
|
||||||
|
|
||||||
|
assertTrue(result.fioF1() >= 0.95,
|
||||||
|
String.format("F1 по ФИО упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(result.overallRecall() >= 0.95,
|
||||||
|
String.format("полнота по всем типам упала до %.3f", result.overallRecall()));
|
||||||
|
assertTrue(result.falsePositiveRate() <= 0.05,
|
||||||
|
String.format("ложные срабатывания на чистых текстах: %.3f", result.falsePositiveRate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отложенный набор: правила на нём не настраивались. Пороги ниже — они
|
||||||
|
* отражают измеренное на нём качество, а не желаемое.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnHoldoutSet() {
|
||||||
|
Result result = measure("/benchmark-holdout.txt", "отложенный набор");
|
||||||
|
|
||||||
|
assertTrue(result.fioF1() >= 0.75,
|
||||||
|
String.format("F1 по ФИО на отложенном наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(result.overallRecall() >= 0.75,
|
||||||
|
String.format("полнота на отложенном наборе упала до %.3f", result.overallRecall()));
|
||||||
|
assertTrue(result.falsePositiveRate() <= 0.15,
|
||||||
|
String.format("ложные срабатывания на отложенном наборе: %.3f", result.falsePositiveRate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Второй контрольный набор, составленный после того, как первый дважды повлиял
|
||||||
|
* на правила. На нём не настраивалось ничего — он и показывает настоящее
|
||||||
|
* качество. Пороги низкие намеренно: тест ловит обвал, а не сторожит значение.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnThirdHoldoutSet() {
|
||||||
|
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
||||||
|
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||||
|
: pipeline;
|
||||||
|
Result result = measure(stage, "/benchmark-holdout3.txt", "второй контрольный набор");
|
||||||
|
|
||||||
|
assertTrue(result.fioF1() >= 0.70,
|
||||||
|
String.format("F1 по ФИО на втором контрольном наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(result.overallRecall() >= 0.70,
|
||||||
|
String.format("полнота на втором контрольном наборе упала до %.3f", result.overallRecall()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контрольный набор. Правила по нему не настраиваются: он существует, чтобы
|
||||||
|
* показывать качество на данных, которых разработка не видела. Пороги здесь
|
||||||
|
* низкие намеренно — тест ловит обвал, а не сторожит достигнутое значение.
|
||||||
|
* Замер идёт со второй ступенью, если модель собрана, иначе на одних правилах.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnSecondHoldoutSet() {
|
||||||
|
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
||||||
|
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||||
|
: pipeline;
|
||||||
|
Result result = measure(stage, "/benchmark-holdout2.txt", "второй отложенный набор");
|
||||||
|
|
||||||
|
assertTrue(result.fioF1() >= 0.70,
|
||||||
|
String.format("F1 по ФИО на втором отложенном наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(result.overallRecall() >= 0.70,
|
||||||
|
String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реальные адреса отделений Альфа-Банка (ловушка из ТЗ — не ПД клиента),
|
||||||
|
* расширенный денилист, обобщённое companion-правило (место рождения,
|
||||||
|
* страна) и новые банковские типы. Собран специально под соответствующие
|
||||||
|
* доработки — пороги ниже, чем у набора отладки, но проверяют именно то,
|
||||||
|
* что было доработано, а не общее качество остального пайплайна.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnBankContextSet() {
|
||||||
|
Result result = measure("/benchmark-bank-context.txt", "банковский контекст");
|
||||||
|
|
||||||
|
assertTrue(result.fioF1() >= 0.70,
|
||||||
|
String.format("F1 по ФИО на банковском наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(result.overallRecall() >= 0.70,
|
||||||
|
String.format("полнота на банковском наборе упала до %.3f", result.overallRecall()));
|
||||||
|
assertTrue(result.falsePositiveRate() <= 0.10,
|
||||||
|
String.format("ложные срабатывания на банковском наборе: %.3f", result.falsePositiveRate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации
|
||||||
|
* написания, не встречавшиеся ни в одном из остальных наборов. Правила под
|
||||||
|
* него не настраивались; пороги низкие по той же причине, что и у второго
|
||||||
|
* отложенного набора — тест ловит обвал, а не сторожит достигнутое значение.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityOnGeneratedSet() {
|
||||||
|
Pipeline stage = Files.isReadable(Path.of(MODEL_PATH))
|
||||||
|
? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4))
|
||||||
|
: pipeline;
|
||||||
|
Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор");
|
||||||
|
|
||||||
|
assertTrue(result.fioF1() >= 0.70,
|
||||||
|
String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1()));
|
||||||
|
assertTrue(result.overallRecall() >= 0.70,
|
||||||
|
String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тот же отложенный набор, но со включённой второй ступенью. Модели нет —
|
||||||
|
* проверка пропускается: в сборке без модели сервис работает на одних правилах.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void detectionQualityWithNameCascade() {
|
||||||
|
Path model = Path.of(MODEL_PATH);
|
||||||
|
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||||
|
|
||||||
|
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
|
||||||
|
new PayloadStore(10_000_000L, 30), new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
||||||
|
Result result = measure(withCascade, "/benchmark-holdout.txt", "отложенный набор, вторая ступень включена");
|
||||||
|
|
||||||
|
assertTrue(result.fioF1() >= 0.75,
|
||||||
|
String.format("F1 по ФИО со второй ступенью упал до %.3f", result.fioF1()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result measure(String resource, String title) {
|
||||||
|
return measure(pipeline, resource, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result measure(Pipeline stage, String resource, String title) {
|
||||||
|
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
|
||||||
|
Map<String, Score> byType = new LinkedHashMap<>();
|
||||||
|
Score anyType = new Score();
|
||||||
|
|
||||||
|
int cleanTexts = 0;
|
||||||
|
int cleanTextsWithFalseHit = 0;
|
||||||
|
int goldFioSpans = 0;
|
||||||
|
int foundFioSpans = 0;
|
||||||
|
List<String> falseHits = new ArrayList<>();
|
||||||
|
List<String> missedFio = new ArrayList<>();
|
||||||
|
List<String> overMasked = new ArrayList<>();
|
||||||
|
|
||||||
|
for (BenchmarkFixtures.Sample sample : samples) {
|
||||||
|
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||||
|
|
||||||
|
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||||
|
String[] foundChars = paint(sample.text().length(), found);
|
||||||
|
|
||||||
|
for (int i = 0; i < sample.text().length(); i++) {
|
||||||
|
account(byType, goldChars[i], foundChars[i]);
|
||||||
|
accountAnyType(anyType, goldChars[i] != null, foundChars[i] != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sample.gold().isEmpty()) {
|
||||||
|
cleanTexts++;
|
||||||
|
if (!found.isEmpty()) {
|
||||||
|
cleanTextsWithFalseHit++;
|
||||||
|
falseHits.add(fragment(sample.text(), found.get(0)) + " ← " + sample.text());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
collectOverMasked(sample.text(), goldChars, foundChars, overMasked);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Span gold : sample.gold()) {
|
||||||
|
if (!RuleRegistry.FIO.equals(gold.type())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
goldFioSpans++;
|
||||||
|
if (overlappedByFio(gold, found)) {
|
||||||
|
foundFioSpans++;
|
||||||
|
} else {
|
||||||
|
missedFio.add(fragment(sample.text(), gold) + " ← " + sample.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report(title, samples.size(), byType, anyType, goldFioSpans, foundFioSpans,
|
||||||
|
cleanTexts, cleanTextsWithFalseHit, missedFio, falseHits, overMasked);
|
||||||
|
|
||||||
|
Score fio = byType.getOrDefault(RuleRegistry.FIO, new Score());
|
||||||
|
double falsePositiveRate = cleanTexts == 0 ? 0.0 : (double) cleanTextsWithFalseHit / cleanTexts;
|
||||||
|
return new Result(fio.f1(), anyType.precision(), anyType.recall(),
|
||||||
|
falsePositiveRate, foundFioSpans, goldFioSpans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||||
|
private static String[] paint(int length, List<Span> spans) {
|
||||||
|
String[] painted = new String[length];
|
||||||
|
for (Span span : spans) {
|
||||||
|
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||||
|
painted[i] = span.type();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return painted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||||
|
if (gold != null) {
|
||||||
|
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||||
|
if (gold.equals(found)) {
|
||||||
|
score.truePositive++;
|
||||||
|
} else {
|
||||||
|
score.falseNegative++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found != null && !found.equals(gold)) {
|
||||||
|
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void accountAnyType(Score score, boolean gold, boolean found) {
|
||||||
|
if (gold && found) {
|
||||||
|
score.truePositive++;
|
||||||
|
} else if (gold) {
|
||||||
|
score.falseNegative++;
|
||||||
|
} else if (found) {
|
||||||
|
score.falsePositive++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean overlappedByFio(Span gold, List<Span> found) {
|
||||||
|
return found.stream()
|
||||||
|
.anyMatch(span -> RuleRegistry.FIO.equals(span.type()) && span.overlaps(gold));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fragment(String text, Span span) {
|
||||||
|
return "«" + text.substring(span.start(), Math.min(span.end(), text.length())) + "»";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Знаки, замаскированные сверх эталона: полезно видеть, где правило берёт лишнее. */
|
||||||
|
private static void collectOverMasked(String text, String[] gold, String[] found, List<String> sink) {
|
||||||
|
int from = -1;
|
||||||
|
for (int i = 0; i <= text.length(); i++) {
|
||||||
|
boolean extra = i < text.length() && found[i] != null && gold[i] == null;
|
||||||
|
if (extra && from < 0) {
|
||||||
|
from = i;
|
||||||
|
} else if (!extra && from >= 0) {
|
||||||
|
sink.add("«" + text.substring(from, i) + "» как " + found[from] + " ← " + text);
|
||||||
|
from = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void report(String title, int samples, Map<String, Score> byType, Score anyType,
|
||||||
|
int goldFio, int foundFio, int cleanTexts, int falseHitTexts,
|
||||||
|
List<String> missedFio, List<String> falseHits, List<String> overMasked) {
|
||||||
|
StringBuilder out = new StringBuilder(4096);
|
||||||
|
out.append("\n=== ").append(title).append(": ").append(samples).append(" размеченных строк ===\n\n");
|
||||||
|
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||||
|
|
||||||
|
byType.entrySet().stream()
|
||||||
|
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||||
|
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||||
|
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
||||||
|
e.getValue().recall(), e.getValue().f1())));
|
||||||
|
|
||||||
|
out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n", "ЛЮБОЙ ТИП", anyType.gold(),
|
||||||
|
anyType.precision(), anyType.recall(), anyType.f1()));
|
||||||
|
|
||||||
|
out.append(String.format("%nФИО пофрагментно: найдено %d из %d (%.1f %%)%n",
|
||||||
|
foundFio, goldFio, goldFio == 0 ? 100.0 : 100.0 * foundFio / goldFio));
|
||||||
|
out.append(String.format("Тексты без ПД: ложные срабатывания на %d из %d (%.1f %%)%n",
|
||||||
|
falseHitTexts, cleanTexts, cleanTexts == 0 ? 0.0 : 100.0 * falseHitTexts / cleanTexts));
|
||||||
|
|
||||||
|
appendList(out, "\nНе найденные ФИО:", missedFio);
|
||||||
|
appendList(out, "\nЛожные срабатывания:", falseHits);
|
||||||
|
appendList(out, "\nЗамаскировано сверх эталона:", overMasked);
|
||||||
|
|
||||||
|
System.out.println(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendList(StringBuilder out, String title, List<String> lines) {
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.append(title).append('\n');
|
||||||
|
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Типы ПД, которые опознаются только рядом с якорным словом. */
|
||||||
|
class ContextDetectionTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private String mask(String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertHidden(String text, String secret) {
|
||||||
|
String masked = mask(text);
|
||||||
|
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksPassportInEveryNotation() {
|
||||||
|
assertHidden("Паспорт 4509 123456 выдан давно", "4509 123456");
|
||||||
|
assertHidden("паспорт гражданина РФ 45 09 123456", "45 09 123456");
|
||||||
|
assertHidden("ПАСПОРТ 4509123456", "4509123456");
|
||||||
|
assertHidden("Серия 4509 номер 123456", "4509");
|
||||||
|
assertHidden("серии 45 09 № 123456", "123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksPassportSeriesAndNumberSplitByWords() {
|
||||||
|
String masked = mask("Документ: серия 4509 номер 123456, выдан отделом");
|
||||||
|
assertTrue(masked.contains("серия "), masked);
|
||||||
|
assertTrue(masked.contains("номер "), masked);
|
||||||
|
assertFalse(masked.contains("4509"), masked);
|
||||||
|
assertFalse(masked.contains("123456"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksDepartmentCode() {
|
||||||
|
assertHidden("Код подразделения 770-001", "770-001");
|
||||||
|
assertHidden("к/п 770001", "770001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksIssuingAuthorityButNotTheDateAfterIt() {
|
||||||
|
String masked = mask("Паспорт выдан ОУФМС России по г. Москве 12.05.2015");
|
||||||
|
assertFalse(masked.contains("ОУФМС"), masked);
|
||||||
|
assertFalse(masked.contains("12.05.2015"), masked);
|
||||||
|
assertTrue(masked.contains("**.**.****"), "дата маскируется отдельно от органа выдачи: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksDriverLicense() {
|
||||||
|
assertHidden("Водительское удостоверение 9902 123456", "9902 123456");
|
||||||
|
assertHidden("в/у 99 02 123456", "99 02 123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCitizenship() {
|
||||||
|
assertHidden("Гражданство: РФ", "РФ");
|
||||||
|
assertHidden("гражданство Республики Беларусь", "Беларусь");
|
||||||
|
assertHidden("Гражданин России обратился", "России");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksBirthPlace() {
|
||||||
|
// Место рождения — тип из requireCompanion: без другого ПД рядом не маскируется
|
||||||
|
// («Нижний Новгород» в рассказе о городе не должен теряться), поэтому в тесте
|
||||||
|
// на распознавание якоря рядом добавлен телефон.
|
||||||
|
assertHidden("Место рождения: город Тверь, проживает в Москве, тел. +7 916 123-45-67", "город Тверь");
|
||||||
|
assertHidden("Родился в Нижнем Новгороде, тел. +7 916 123-45-67", "Нижнем Новгороде");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskBirthPlaceWithoutAnyOtherPersonalData() {
|
||||||
|
String text = "Экскурсия в Нижний Новгород перенесена на май";
|
||||||
|
assertEquals(text, mask(text), "место рождения без другого ПД рядом не маскируется");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksBirthPlaceWhenOtherPersonalDataIsAlsoPresent() {
|
||||||
|
assertHidden("Место рождения: город Тверь, ИНН 770301234550", "город Тверь");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskCountryWithoutAnyOtherPersonalData() {
|
||||||
|
String text = "Цены на нефть выросли в Казахстане в этом квартале";
|
||||||
|
assertEquals(text, mask(text), "страна без другого ПД рядом не маскируется");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCountryWhenOtherPersonalDataIsAlsoPresent() {
|
||||||
|
assertHidden("Страна проживания Казахстан, ИНН 770301234550", "Казахстан");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCardholderName() {
|
||||||
|
assertHidden("Держатель карты IVAN PETROV", "IVAN PETROV");
|
||||||
|
assertHidden("cardholder: PETR SIDOROV", "PETR SIDOROV");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksSecurityCodeAndPinCompletely() {
|
||||||
|
String masked = mask("Карта 4111 1111 1111 1111, CVV 123, пин-код 4321");
|
||||||
|
assertFalse(masked.contains("123,"), masked);
|
||||||
|
assertFalse(masked.contains("4321"), masked);
|
||||||
|
assertTrue(masked.contains("***"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskPinWithoutAnyOtherPersonalData() {
|
||||||
|
String text = "Пин-код 1234 введён неверно";
|
||||||
|
assertEquals(text, mask(text), "одиночный пин-код персональными данными не является");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksPinWhenCardNumberIsAlsoPresent() {
|
||||||
|
assertHidden("Пин-код 1234 от карты 4111 1111 1111 1111", "1234 от");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anchorWordsAreCaseInsensitive() {
|
||||||
|
assertHidden("ПАСПОРТ СЕРИЯ 4509 НОМЕР 123456", "123456");
|
||||||
|
assertHidden("гРаЖдАнСтВо РФ, паспорт 4509 123456", "4509 123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void complexSentenceKeepsSurroundingWords() {
|
||||||
|
String original = "Клиент, паспорт 4509 123456 выдан ОУФМС по г. Москве, "
|
||||||
|
+ "код подразделения 770-001, ИНН 770301234550, телефон +7 916 123-45-67";
|
||||||
|
String masked = mask(original);
|
||||||
|
|
||||||
|
assertTrue(masked.startsWith("Клиент, паспорт "), masked);
|
||||||
|
assertTrue(masked.contains("код подразделения"), masked);
|
||||||
|
assertTrue(masked.contains("телефон"), masked);
|
||||||
|
assertFalse(masked.contains("4509 123456"), masked);
|
||||||
|
assertFalse(masked.contains("770301234550"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmaskingRestoresComplexSentence() {
|
||||||
|
String original = "Паспорт 4509 123456, выдан ОУФМС России по г. Москве, "
|
||||||
|
+ "код подразделения 770-001, гражданство РФ, CVV 123, карта 4111 1111 1111 1111";
|
||||||
|
String id = "complex-1";
|
||||||
|
|
||||||
|
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
|
assertFalse(masked.contains("4509 123456"), masked);
|
||||||
|
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Даты во всех вариантах записи и составляющие адреса. */
|
||||||
|
class DateAndAddressTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private String mask(String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertHidden(String text, String secret) {
|
||||||
|
String masked = mask(text);
|
||||||
|
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksBirthDateInAnyPartOrder() {
|
||||||
|
assertHidden("Дата рождения 12.05.1985", "12.05.1985");
|
||||||
|
assertHidden("дата рождения: 05/12/1985", "05/12/1985");
|
||||||
|
assertHidden("Дата рождения 1985-12-05", "1985-12-05");
|
||||||
|
assertHidden("Родился 12-05-1985", "12-05-1985");
|
||||||
|
assertHidden("12.05.1985 г.р. — данные клиента", "12.05.1985");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksBirthDateWrittenWithWords() {
|
||||||
|
assertHidden("Дата рождения: 12 мая 1985 года", "12 мая 1985");
|
||||||
|
assertHidden("Дата рождения двенадцатого мая тысяча девятьсот восемьдесят пятого года",
|
||||||
|
"двенадцатого мая");
|
||||||
|
assertHidden("Дата рождения: двадцать первого августа 1990 года", "двадцать первого августа");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsSeparatorsInMaskedDate() {
|
||||||
|
String masked = mask("Дата рождения 12.05.1985");
|
||||||
|
assertTrue(masked.endsWith("**.**.****"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksPassportIssueDate() {
|
||||||
|
assertHidden("Паспорт 4509 123456, дата выдачи 12.05.2015", "12.05.2015");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskDateWithoutAnyOtherPersonalData() {
|
||||||
|
String text = "Встреча перенесена на 12.05.2025, подтвердите";
|
||||||
|
assertEquals(text, mask(text), "дата сама по себе персональными данными не является");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksBareDateWhenOtherPersonalDataIsPresent() {
|
||||||
|
assertHidden("Паспорт 4509 123456 оформлен 12.05.2015", "12.05.2015");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotTreatVersionOrAddressLikeNumbersAsDate() {
|
||||||
|
String text = "Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута";
|
||||||
|
assertEquals(text, mask(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksAddressComponentsSeparately() {
|
||||||
|
String masked = mask("Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15");
|
||||||
|
assertFalse(masked.contains("125009"), masked);
|
||||||
|
assertFalse(masked.contains("Москва"), masked);
|
||||||
|
assertFalse(masked.contains("Тверская"), masked);
|
||||||
|
assertTrue(masked.contains("г. "), "указатели вида «г.», «ул.» остаются: " + masked);
|
||||||
|
assertTrue(masked.contains("ул. "), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streetNameDoesNotSwallowTheRestOfTheSentence() {
|
||||||
|
String masked = mask("Адрес клиента: ул. Сосновая перекрыта из-за ремонта");
|
||||||
|
assertTrue(masked.contains("перекрыта из-за ремонта"),
|
||||||
|
"название улицы это одно-три слова, а не остаток предложения: " + masked);
|
||||||
|
assertFalse(masked.contains("Сосновая"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskStreetMentionedOutsideAnAddress() {
|
||||||
|
assertEquals("Проспект Мира перекрыт до вечера", mask("Проспект Мира перекрыт до вечера"));
|
||||||
|
assertEquals("Улица Весенняя названа в честь праздника",
|
||||||
|
mask("Улица Весенняя названа в честь праздника"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksMultiWordStreetName() {
|
||||||
|
String masked = mask("Адрес: г. Москва, ул. Малая Никитская, д. 4");
|
||||||
|
assertFalse(masked.contains("Малая Никитская"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksIndexByAnchorWord() {
|
||||||
|
assertHidden("Индекс 125009 для доставки клиенту Иванову, паспорт 4509 123456", "125009");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskBankBranchAddress() {
|
||||||
|
String text = "Отделение банка на улице Тверская, дом 7 работает до 20:00";
|
||||||
|
assertEquals(text, mask(text), "адрес отделения банка персональными данными не является");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskOfficeAddress() {
|
||||||
|
String text = "Дополнительный офис, г. Москва, ул. Арбат, д. 1";
|
||||||
|
assertEquals(text, mask(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addressTypesAreConfigurableSeparately() {
|
||||||
|
SystemPolicy onlyCity = SystemPolicy.forTypes(RuleRegistry.ADDRESS_CITY);
|
||||||
|
String masked = pipeline.process("г. Москва, ул. Тверская, д. 7", "addr-1", onlyCity);
|
||||||
|
|
||||||
|
assertFalse(masked.contains("Москва"), masked);
|
||||||
|
assertTrue(masked.contains("Тверская"), "улица этой системой не маскируется: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmaskingRestoresTextWithDateAndAddress() {
|
||||||
|
String original = "Иванов, дата рождения 12.05.1985, адрес: 125009, г. Москва, "
|
||||||
|
+ "ул. Тверская, д. 7, кв. 15, паспорт 4509 123456";
|
||||||
|
String id = "date-addr-1";
|
||||||
|
|
||||||
|
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
|
assertFalse(masked.contains("12.05.1985"), masked);
|
||||||
|
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** ФИО и защита от ложных срабатываний. */
|
||||||
|
class FioTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private String mask(String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertHidden(String text, String secret) {
|
||||||
|
String masked = mask(text);
|
||||||
|
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertUnchanged(String text) {
|
||||||
|
assertEquals(text, mask(text), "ложное срабатывание");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksFullNameAsInitials() {
|
||||||
|
assertEquals("Клиент И. И. И. обратился", mask("Клиент Иванов Иван Иванович обратился"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksNameAndPatronymicWithoutSurname() {
|
||||||
|
assertHidden("Пригласите Ивана Сергеевича на встречу", "Ивана Сергеевича");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksFemalePatronymic() {
|
||||||
|
assertHidden("Петрова Анна Ивановна подписала", "Петрова Анна Ивановна");
|
||||||
|
assertHidden("Мария Никитична ждёт ответа", "Мария Никитична");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksSurnameWithInitialsInBothOrders() {
|
||||||
|
assertHidden("Заявление от Иванов И.И. принято", "Иванов И.И.");
|
||||||
|
assertHidden("Подписал И.И. Иванов", "И.И. Иванов");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksSurnameNextToKnownGivenName() {
|
||||||
|
assertHidden("Петров Сергей подтвердил заявку", "Петров Сергей");
|
||||||
|
assertHidden("Сергей Петров подтвердил заявку", "Сергей Петров");
|
||||||
|
assertHidden("Обращение Ольги Ковалёвой рассмотрено", "Ольги Ковалёвой");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksLowercaseNameAfterExplicitAnchor() {
|
||||||
|
assertHidden("ФИО: иванов иван иванович", "иванов иван иванович");
|
||||||
|
assertHidden("Карта оформлена на имя петров сергей", "петров сергей");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksNameAfterRoleAnchor() {
|
||||||
|
assertHidden("Клиент Петров Сергей, заявка одобрена", "Петров Сергей");
|
||||||
|
assertHidden("Плательщик Ковалёва подтвердила перевод", "Ковалёва");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskWellKnownPerson() {
|
||||||
|
assertUnchanged("Напиши стихотворение в духе Александра Пушкина про осень");
|
||||||
|
assertUnchanged("Сравни Толстого и Достоевского как прозаиков");
|
||||||
|
assertUnchanged("Когда Гагарин полетел в космос");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksWellKnownSurnameWhenOtherPersonalDataIsPresent() {
|
||||||
|
assertHidden("Клиент Александр Пушкин, паспорт 4509 123456", "Александр Пушкин");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskPlaceNamesThatLookLikeSurnames() {
|
||||||
|
assertUnchanged("Московский Кремль открыт для посещения");
|
||||||
|
assertUnchanged("Экскурсия в Нижний Новгород перенесена");
|
||||||
|
assertUnchanged("Смоленская площадь закрыта на ремонт");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskOrdinaryCapitalisedWords() {
|
||||||
|
assertUnchanged("Банк Открытие подтвердил лимит");
|
||||||
|
assertUnchanged("В Понедельник Отдел Согласует Договор");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void identificationIgnoresCase() {
|
||||||
|
assertHidden("ИВАНОВ ИВАН ИВАНОВИЧ", "ИВАНОВ ИВАН ИВАНОВИЧ");
|
||||||
|
assertHidden("фио: петрова анна ивановна", "петрова анна ивановна");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmaskingRestoresNames() {
|
||||||
|
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456, "
|
||||||
|
+ "дата рождения 12.05.1985, телефон +7 916 123-45-67";
|
||||||
|
String id = "fio-1";
|
||||||
|
|
||||||
|
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
|
assertFalse(masked.contains("Иванов Иван Иванович"), masked);
|
||||||
|
assertTrue(masked.contains("И. И. И."), masked);
|
||||||
|
assertEquals(original, pipeline.process(masked, id, SystemPolicy.DEFAULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void namesStayFastOnLargeText() {
|
||||||
|
String block = "Клиент Иванов Иван Иванович, паспорт 4509 123456, город Москва. ";
|
||||||
|
String large = block.repeat(4000);
|
||||||
|
|
||||||
|
long started = System.nanoTime();
|
||||||
|
String masked = pipeline.process(large, "fio-large", SystemPolicy.DEFAULT);
|
||||||
|
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||||
|
|
||||||
|
assertFalse(masked.contains("Иванов Иван Иванович"));
|
||||||
|
assertTrue(millis < 1000, "обработка заняла " + millis + " мс");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
/** Документы, удостоверяющие личность, помимо паспорта РФ. */
|
||||||
|
class IdentityDocumentTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private void assertHidden(String text, String secret) {
|
||||||
|
String masked = pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertMasked(String text, String payloadId, String expected) {
|
||||||
|
assertEquals(expected, pipeline.process(text, payloadId, SystemPolicy.DEFAULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksForeignPassport() {
|
||||||
|
assertHidden("Загранпаспорт 75 1234567 действителен до 2030 года", "75 1234567");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksMilitaryId() {
|
||||||
|
assertHidden("Военный билет АБ 1234567 предъявлен", "АБ 1234567");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksBirthCertificate() {
|
||||||
|
assertHidden("Свидетельство о рождении II-МЮ № 123456", "II-МЮ № 123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksMedicalPolicy() {
|
||||||
|
assertHidden("Полис ОМС 1234567890123456 оформлен", "1234567890123456");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* У загранпаспорта, военного билета и свидетельства о рождении серия короткая —
|
||||||
|
* две цифры или две буквы. Открой маска первые два знака, серия была бы видна
|
||||||
|
* целиком, поэтому у этих документов открыты только последние знаки номера.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void hidesShortDocumentSeriesCompletely() {
|
||||||
|
assertMasked("Загранпаспорт 75 1234567", "fp-1", "Загранпаспорт ** *****67");
|
||||||
|
assertMasked("Военный билет АБ 1234567", "mil-1", "Военный билет ** *****67");
|
||||||
|
assertMasked("Свидетельство о рождении II-МЮ № 123456", "bc-1",
|
||||||
|
"Свидетельство о рождении **-** № ****56");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** У паспорта РФ и водительского удостоверения серия из четырёх знаков — открыта половина. */
|
||||||
|
@Test
|
||||||
|
void keepsHalfOfFourCharacterSeries() {
|
||||||
|
assertMasked("Паспорт 4509 123456", "rf-1", "Паспорт 45** ****56");
|
||||||
|
assertMasked("Водительское удостоверение 9902 123456", "dl-1",
|
||||||
|
"Водительское удостоверение 99** ****56");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Качество и скорость на большом тексте — не повторе одного и того же
|
||||||
|
* предложения, а перемешанных строках из {@code benchmark-generated.txt}
|
||||||
|
* (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ
|
||||||
|
* (около 100 000 токенов, ~400 КБ по оценке из README).
|
||||||
|
*
|
||||||
|
* <p>Раздутый повтором одной строки текст проверяет только то, что цикл не
|
||||||
|
* падает на объёме: под маской всегда один и тот же тип, а остальные правила
|
||||||
|
* не задействуются вовсе. Здесь размер и разнообразие проверяются вместе.
|
||||||
|
*/
|
||||||
|
class LargeTextTest {
|
||||||
|
|
||||||
|
private static final String ENGINE = System.getProperty("bench.engine", "rubert");
|
||||||
|
private static final String MODEL_PATH = System.getProperty("bench.model", "models/rubert-ner");
|
||||||
|
|
||||||
|
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
|
||||||
|
private static final int TARGET_CHARS = 400_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перемешивает исходные строки (фиксированный seed — детерминированный
|
||||||
|
* тест) и склеивает их через перенос строки, пока не наберётся целевой
|
||||||
|
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
|
||||||
|
*/
|
||||||
|
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
|
||||||
|
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
|
||||||
|
Random random = new Random(seed);
|
||||||
|
StringBuilder text = new StringBuilder(targetChars + 1024);
|
||||||
|
List<Span> gold = new ArrayList<>();
|
||||||
|
|
||||||
|
while (text.length() < targetChars) {
|
||||||
|
Collections.shuffle(pool, random);
|
||||||
|
for (BenchmarkFixtures.Sample sample : pool) {
|
||||||
|
int offset = text.length();
|
||||||
|
text.append(sample.text()).append('\n');
|
||||||
|
for (Span span : sample.gold()) {
|
||||||
|
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
||||||
|
}
|
||||||
|
if (text.length() >= targetChars) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маскирование и обратное преобразование на большом тексте дают
|
||||||
|
* побайтово тот же результат, что и исходный текст — при объёме на
|
||||||
|
* порядок больше, чем в остальных тестах, и с разнородным содержимым,
|
||||||
|
* а не одним повторяющимся предложением.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void roundTripOnLargeMixedText() {
|
||||||
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
|
||||||
|
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||||
|
new PayloadStore(large.text().length() * 2L, 30));
|
||||||
|
|
||||||
|
long maskStarted = System.nanoTime();
|
||||||
|
String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT);
|
||||||
|
long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000;
|
||||||
|
|
||||||
|
long unmaskStarted = System.nanoTime();
|
||||||
|
String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT);
|
||||||
|
long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000;
|
||||||
|
|
||||||
|
assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст");
|
||||||
|
assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс");
|
||||||
|
assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс");
|
||||||
|
|
||||||
|
System.out.printf("%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n",
|
||||||
|
large.text().length(), maskMillis, unmaskMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полнота детекции не должна проседать на объёме: каждый золотой
|
||||||
|
* фрагмент из перемешанных строк обязан быть найден в общем потоке
|
||||||
|
* текста, а не только когда он единственный в маленькой строке.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void recallHoldsAtScale() {
|
||||||
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2);
|
||||||
|
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30));
|
||||||
|
|
||||||
|
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
|
||||||
|
int hit = 0;
|
||||||
|
for (Span gold : large.gold()) {
|
||||||
|
if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) {
|
||||||
|
hit++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size();
|
||||||
|
System.out.printf("%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall);
|
||||||
|
|
||||||
|
assertTrue(recall >= 0.85,
|
||||||
|
String.format("полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вторая ступень ограничена числом кандидатов на запрос
|
||||||
|
* ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен
|
||||||
|
* превращать её в квадратичную нагрузку — проверяем на том же большом
|
||||||
|
* тексте, что и остальные тесты, а не на маленьком образце.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void nameCascadeStaysBoundedOnLargeText() {
|
||||||
|
Path model = Path.of(MODEL_PATH);
|
||||||
|
if (!Files.isReadable(model)) {
|
||||||
|
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
|
||||||
|
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||||
|
new PayloadStore(large.text().length() * 2L, 30),
|
||||||
|
new NameCascade(ENGINE, Optional.of(MODEL_PATH), 16, 4));
|
||||||
|
|
||||||
|
long started = System.nanoTime();
|
||||||
|
pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT);
|
||||||
|
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||||
|
|
||||||
|
System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n",
|
||||||
|
large.text().length(), millis);
|
||||||
|
assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.detect.Validators;
|
||||||
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Виды замены: звёздочки, токены, правдоподобные значения. */
|
||||||
|
class MaskModeTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private SystemPolicy policy(MaskMode mode) {
|
||||||
|
return new SystemPolicy(SystemPolicy.DEFAULT_NAME, true, true, mode,
|
||||||
|
Set.of(SystemPolicy.ALL), SystemPolicy.DEFAULT.requireCompanion(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mask(MaskMode mode, String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), policy(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tokenModeNumbersEachType() {
|
||||||
|
String masked = mask(MaskMode.TOKEN, "Клиент Иванов Иван Иванович, почта ivan@mail.ru");
|
||||||
|
assertTrue(masked.contains("[FIO_1]"), masked);
|
||||||
|
assertTrue(masked.contains("[EMAIL_1]"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameValueGetsSameTokenWithinRequest() {
|
||||||
|
String masked = mask(MaskMode.TOKEN, "ivan@mail.ru и ещё раз ivan@mail.ru, а также petr@mail.ru");
|
||||||
|
assertEquals(2, count(masked, "[EMAIL_1]"), masked);
|
||||||
|
assertEquals(1, count(masked, "[EMAIL_2]"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syntheticModeProducesPlausibleValues() {
|
||||||
|
String masked = mask(MaskMode.SYNTHETIC, "Карта 4111 1111 1111 1111 клиента Иванова Ивана");
|
||||||
|
|
||||||
|
assertFalse(masked.contains("4111 1111 1111 1111"), masked);
|
||||||
|
assertFalse(masked.contains("*"), "подстановка должна выглядеть настоящей: " + masked);
|
||||||
|
|
||||||
|
Matcher card = Pattern.compile("\\d{4} \\d{4} \\d{4} \\d{4}").matcher(masked);
|
||||||
|
assertTrue(card.find(), masked);
|
||||||
|
assertTrue(Validators.luhn(card.group()), "подставленный номер карты обязан проходить проверку Луна");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syntheticValuesAreStable() {
|
||||||
|
String text = "Почта ivan@mail.ru, паспорт 4509 123456";
|
||||||
|
assertEquals(mask(MaskMode.SYNTHETIC, text), mask(MaskMode.SYNTHETIC, text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmaskingWorksInEveryMode() {
|
||||||
|
String original = "Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111, почта ivan@mail.ru";
|
||||||
|
for (MaskMode mode : MaskMode.values()) {
|
||||||
|
String id = "mode-" + mode;
|
||||||
|
String masked = pipeline.process(original, id, policy(mode));
|
||||||
|
assertFalse(masked.contains("Иванов Иван Иванович"), mode + ": " + masked);
|
||||||
|
assertEquals(original, pipeline.process(masked, id, policy(mode)), mode.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int count(String text, String fragment) {
|
||||||
|
int n = 0;
|
||||||
|
for (int i = text.indexOf(fragment); i >= 0; i = text.indexOf(fragment, i + fragment.length())) {
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
/** Вторая ступень не должна вредить первой. */
|
||||||
|
class NameCascadeTest {
|
||||||
|
|
||||||
|
private static final String TEXT = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||||
|
|
||||||
|
private String mask(NameCascade cascade, String payloadId) {
|
||||||
|
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||||
|
new PayloadStore(1_000_000L, 30), cascade);
|
||||||
|
return pipeline.process(TEXT, payloadId, SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutModelTheStageIsOff() {
|
||||||
|
NameCascade cascade = NameCascade.disabled();
|
||||||
|
assertFalse(cascade.enabled());
|
||||||
|
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "off-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingModelFileDoesNotBreakMasking(@TempDir Path dir) {
|
||||||
|
NameCascade cascade = new NameCascade("rubert", Optional.of(dir.resolve("нет-такого-каталога").toString()), 16, 4);
|
||||||
|
assertFalse(cascade.enabled(), "отсутствующая модель должна выключать ступень");
|
||||||
|
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "missing-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void brokenModelFileDoesNotBreakMasking(@TempDir Path dir) throws IOException {
|
||||||
|
Path broken = dir.resolve("испорченная-модель");
|
||||||
|
Files.createDirectories(broken);
|
||||||
|
for (String name : new String[]{"model_int8.onnx", "vocab.txt", "config.json"}) {
|
||||||
|
Files.writeString(broken.resolve(name), "это не модель", StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
NameCascade cascade = new NameCascade("rubert", Optional.of(broken.toString()), 16, 4);
|
||||||
|
assertFalse(cascade.enabled(), "испорченная модель должна выключать ступень");
|
||||||
|
assertEquals("Клиент И. И. И., паспорт 45** ****56", mask(cascade, "broken-1"),
|
||||||
|
"маскирование по правилам обязано работать и без второй ступени");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Имя в названии организации или объекта на карте персональными данными не является.
|
||||||
|
* Решает слово перед именем, а не само имя: однофамилец защиту не теряет.
|
||||||
|
*/
|
||||||
|
class OrganisationNamesTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private String mask(String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsNamesInsideInstitutionNames() {
|
||||||
|
for (String text : new String[]{
|
||||||
|
"Институт Мечникова принимает по записи",
|
||||||
|
"Музей Верещагина работает по будням",
|
||||||
|
"Театр Станиславского открыл сезон",
|
||||||
|
"Библиотека Некрасова закрыта на ремонт",
|
||||||
|
"Премия имени Ломоносова вручена в декабре",
|
||||||
|
"Больница Боткина приняла пациентов",
|
||||||
|
"Стадион Яшина отремонтирован"}) {
|
||||||
|
assertEquals(text, mask(text), "имя в названии учреждения маскировать не нужно");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsNamesInsidePlaceNames() {
|
||||||
|
for (String text : new String[]{
|
||||||
|
"Улица Королёва названа в честь конструктора",
|
||||||
|
"Проспект Вернадского перекрыт до вечера",
|
||||||
|
"Площадь Гагарина находится на юго-западе",
|
||||||
|
"Набережная Макарова уходит к заливу",
|
||||||
|
"Мост Кадырова разведут ночью"}) {
|
||||||
|
assertEquals(text, mask(text), "топоним маскировать не нужно");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksRealClientWithTheSameSurname() {
|
||||||
|
String masked = mask("Клиент Королёв Сергей Павлович, паспорт 4509 123456");
|
||||||
|
assertFalse(masked.contains("Королёв Сергей Павлович"),
|
||||||
|
"однофамилец объекта на карте остаётся под защитой: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markerOnlyCountsRightBeforeTheName() {
|
||||||
|
String masked = mask("Больница приняла Иванова Ивана Ивановича с жалобой");
|
||||||
|
assertFalse(masked.contains("Иванова Ивана Ивановича"),
|
||||||
|
"слово-маркер действует только вплотную перед именем: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsRulerNames() {
|
||||||
|
for (String text : new String[]{
|
||||||
|
"Василий Тёмный правил недолго",
|
||||||
|
"Ярослав Мудрый составил свод законов",
|
||||||
|
"Екатерина Вторая издала указ",
|
||||||
|
"Алексей Тишайший принимал послов"}) {
|
||||||
|
assertEquals(text, mask(text), "имя правителя персональными данными не является");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksClientEvenIfNameLooksRegnal() {
|
||||||
|
String masked = mask("Клиент Василий Тёмный, паспорт 4509 123456");
|
||||||
|
assertFalse(masked.contains("Василий Тёмный"),
|
||||||
|
"рядом с паспортными данными это конкретный человек: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotSuppressSoleTraderName() {
|
||||||
|
String masked = mask("ИП Пахомов Вениамин Николаевич, ИНН 502601234547");
|
||||||
|
assertFalse(masked.contains("Пахомов Вениамин Николаевич"),
|
||||||
|
"имя предпринимателя — это персональные данные: " + masked);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Ограничения хранилища соответствий: объём, срок жизни и разделение по системам. */
|
||||||
|
class PayloadStoreTest {
|
||||||
|
|
||||||
|
private static final String SYSTEM = "crm";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsWhatWasStored() {
|
||||||
|
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||||
|
store.put(SYSTEM, "id", "исходный текст", "маска");
|
||||||
|
|
||||||
|
PayloadStore.Entry entry = store.byId(SYSTEM, "id");
|
||||||
|
assertNotNull(entry);
|
||||||
|
assertEquals("исходный текст", entry.original());
|
||||||
|
assertEquals("маска", entry.masked());
|
||||||
|
assertEquals("исходный текст", store.originalForMask(SYSTEM, "маска"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forgetsEntriesAfterTheirLifetime() {
|
||||||
|
PayloadStore store = new PayloadStore(1_000_000L, 0);
|
||||||
|
store.put(SYSTEM, "id", "исходный текст", "маска");
|
||||||
|
|
||||||
|
assertNull(store.byId(SYSTEM, "id"), "запись с истёкшим сроком жизни не должна отдаваться");
|
||||||
|
assertNull(store.originalForMask(SYSTEM, "маска"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evictsOldestWhenOverSizeLimit() {
|
||||||
|
PayloadStore store = new PayloadStore(100L, 30);
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
store.put(SYSTEM, "id" + i, "текст номер " + i, "маска номер " + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(store.charsHeld() <= 100, "объём хранилища вышел за предел: " + store.charsHeld());
|
||||||
|
assertNull(store.byId(SYSTEM, "id0"), "самая старая запись должна быть вытеснена");
|
||||||
|
assertNotNull(store.byId(SYSTEM, "id49"), "последняя запись должна остаться");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownKeysReturnNothing() {
|
||||||
|
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||||
|
assertNull(store.byId(SYSTEM, "нет такого"));
|
||||||
|
assertNull(store.originalForMask(SYSTEM, "нет такой маски"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск по маске идёт только внутри своей системы. Маски детерминированы и
|
||||||
|
* низкоэнтропийны: без разделения чужую маску можно было бы подобрать и обменять
|
||||||
|
* на исходные данные другого потребителя.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void oneSystemCannotReadAnotherSystemData() {
|
||||||
|
PayloadStore store = new PayloadStore(1_000_000L, 30);
|
||||||
|
store.put("crm", "общий-id", "Иванов Иван Иванович", "И. И. И.");
|
||||||
|
|
||||||
|
assertNull(store.originalForMask("analytics", "И. И. И."),
|
||||||
|
"чужую маску нельзя обменять на исходный текст");
|
||||||
|
assertNull(store.byId("analytics", "общий-id"),
|
||||||
|
"совпадение идентификатора у другой системы не даёт доступа");
|
||||||
|
assertEquals("Иванов Иван Иванович", store.originalForMask("crm", "И. И. И."),
|
||||||
|
"своя система свои данные по-прежнему получает");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.detect.Span;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оценка эффективности детекции по каждому типу ПДН в отдельности.
|
||||||
|
*
|
||||||
|
* <p>Набор {@code benchmark-pdn-types.txt} содержит по несколько примеров каждого
|
||||||
|
* типа ПДН. Для каждого типа считается посимвольная точность, полнота и F1 —
|
||||||
|
* так видно, какие типы детектор находит надёжно, а какие пропускает или
|
||||||
|
* маскирует сверх меры.
|
||||||
|
*/
|
||||||
|
class PdnTypeEfficiencyTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||||
|
|
||||||
|
/** Накопитель посимвольных совпадений по одному типу. */
|
||||||
|
private static final class Score {
|
||||||
|
private int truePositive;
|
||||||
|
private int falsePositive;
|
||||||
|
private int falseNegative;
|
||||||
|
|
||||||
|
private int gold() {
|
||||||
|
return truePositive + falseNegative;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double precision() {
|
||||||
|
int found = truePositive + falsePositive;
|
||||||
|
return found == 0 ? 1.0 : (double) truePositive / found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double recall() {
|
||||||
|
return gold() == 0 ? 1.0 : (double) truePositive / gold();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double f1() {
|
||||||
|
double p = precision();
|
||||||
|
double r = recall();
|
||||||
|
return p + r == 0 ? 0.0 : 2 * p * r / (p + r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void efficiencyByPdnType() {
|
||||||
|
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load("/benchmark-pdn-types.txt");
|
||||||
|
Map<String, Score> byType = new LinkedHashMap<>();
|
||||||
|
Map<String, List<String>> missed = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (BenchmarkFixtures.Sample sample : samples) {
|
||||||
|
List<Span> found = pipeline.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||||
|
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||||
|
String[] foundChars = paint(sample.text().length(), found);
|
||||||
|
for (int i = 0; i < sample.text().length(); i++) {
|
||||||
|
account(byType, goldChars[i], foundChars[i]);
|
||||||
|
}
|
||||||
|
for (Span gold : sample.gold()) {
|
||||||
|
boolean hit = found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold));
|
||||||
|
if (!hit) {
|
||||||
|
missed.computeIfAbsent(gold.type(), t -> new ArrayList<>())
|
||||||
|
.add(sample.text().substring(gold.start(), gold.end()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report(byType);
|
||||||
|
reportMissed(missed);
|
||||||
|
|
||||||
|
// Каждый тип должен быть найден с F1 не ниже 0.8 — иначе детектор
|
||||||
|
// пропускает или перемаскирует этот тип ПДН. Companion-типы (CVV, PIN,
|
||||||
|
// DATE) проверяются отдельно: они маскируются только рядом с другими ПД.
|
||||||
|
for (Map.Entry<String, Score> e : byType.entrySet()) {
|
||||||
|
if (isCompanion(e.getKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assertTrue(e.getValue().f1() >= 0.8,
|
||||||
|
String.format("F1 по типу %s упал до %.3f", e.getKey(), e.getValue().f1()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reportMissed(Map<String, List<String>> missed) {
|
||||||
|
if (missed.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
out.append("\n=== Не распознанные значения по типам ===\n");
|
||||||
|
missed.forEach((type, values) -> {
|
||||||
|
out.append(type).append(": ").append(String.join(" | ", values)).append('\n');
|
||||||
|
});
|
||||||
|
System.out.println(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isCompanion(String type) {
|
||||||
|
return "CVV".equals(type) || "PIN".equals(type) || "DATE".equals(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Раскрашивает каждый знак текста типом ПД, который его покрывает. */
|
||||||
|
private static String[] paint(int length, List<Span> spans) {
|
||||||
|
String[] painted = new String[length];
|
||||||
|
for (Span span : spans) {
|
||||||
|
for (int i = span.start(); i < Math.min(span.end(), length); i++) {
|
||||||
|
painted[i] = span.type();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return painted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void account(Map<String, Score> byType, String gold, String found) {
|
||||||
|
if (gold != null) {
|
||||||
|
Score score = byType.computeIfAbsent(gold, t -> new Score());
|
||||||
|
if (gold.equals(found)) {
|
||||||
|
score.truePositive++;
|
||||||
|
} else {
|
||||||
|
score.falseNegative++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found != null && !found.equals(gold)) {
|
||||||
|
byType.computeIfAbsent(found, t -> new Score()).falsePositive++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void report(Map<String, Score> byType) {
|
||||||
|
StringBuilder out = new StringBuilder(2048);
|
||||||
|
out.append("\n=== Эффективность детекции по типам ПДН ===\n\n");
|
||||||
|
out.append(String.format("%-20s %8s %8s %8s %8s%n", "тип", "знаков", "точность", "полнота", "F1"));
|
||||||
|
|
||||||
|
byType.entrySet().stream()
|
||||||
|
.sorted(Comparator.comparingInt((Map.Entry<String, Score> e) -> e.getValue().gold()).reversed())
|
||||||
|
.forEach(e -> out.append(String.format("%-20s %8d %8.3f %8.3f %8.3f%n",
|
||||||
|
e.getKey(), e.getValue().gold(), e.getValue().precision(),
|
||||||
|
e.getValue().recall(), e.getValue().f1())));
|
||||||
|
|
||||||
|
System.out.println(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import io.micrometer.core.instrument.MeterRegistry;
|
||||||
|
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.NameCascade;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Замер производительности: задержка одиночного обращения и пропускная
|
||||||
|
* способность под нагрузкой. Не тест качества — он в {@link BenchmarkTest}.
|
||||||
|
*
|
||||||
|
* <p>Прогон идёт на одних правилах (вторая ступень выключена), как в боевой
|
||||||
|
* сборке без модели. Перед замером пайплайн прогревается, чтобы JIT успел
|
||||||
|
* скомпилировать горячий путь, — иначе первые замеры покажут интерпретируемый
|
||||||
|
* код и занизят результат в разы.
|
||||||
|
*/
|
||||||
|
class PerformanceBenchmarkTest {
|
||||||
|
|
||||||
|
/** Типовой текст с ПД — как в реальном обращении. */
|
||||||
|
private static final String[] PAYLOADS = {
|
||||||
|
"Клиент Иванов Иван Иванович, паспорт 4509 123456, тел +7 916 123-45-67",
|
||||||
|
"Заявление от И.И. Петрова, ИНН 770301234550, почта ivan.petrov@mail.ru",
|
||||||
|
"Адрес: 125009, г. Москва, ул. Тверская, д. 7, кв. 15, карта 4111 1111 1111 1111",
|
||||||
|
"Дата рождения 12.05.1985, место рождения: город Тверь, гражданство РФ",
|
||||||
|
"Напиши краткое описание продукта для рассылки клиентам банка",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тексты, где правила не находят ПД, но есть цепочки имён — их разбирает
|
||||||
|
* вторая ступень (модель). Нужны, чтобы честно измерить стоимость модели,
|
||||||
|
* а не правила, которые в типовых текстах уже всё покрыли.
|
||||||
|
*/
|
||||||
|
private static final String[] CASCADE_PAYLOADS = {
|
||||||
|
"Готье и Руссо пришли на встречу в офис",
|
||||||
|
"Дюма написал роман за несколько месяцев",
|
||||||
|
"Виктор Гюго был известным писателем",
|
||||||
|
"Оноре де Бальзак писал романы о жизни",
|
||||||
|
"Жан-Поль Сартр философ и писатель",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final int WARMUP = 20_000;
|
||||||
|
private static final int MEASURE = 50_000;
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30));
|
||||||
|
|
||||||
|
private void warmup() {
|
||||||
|
for (int i = 0; i < WARMUP; i++) {
|
||||||
|
String text = PAYLOADS[i % PAYLOADS.length];
|
||||||
|
String id = "warmup-" + i;
|
||||||
|
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Задержка маскирования и демаскирования типового обращения. */
|
||||||
|
@Test
|
||||||
|
void singleRequestLatency() {
|
||||||
|
warmup();
|
||||||
|
|
||||||
|
long[] maskNanos = new long[MEASURE];
|
||||||
|
long[] unmaskNanos = new long[MEASURE];
|
||||||
|
|
||||||
|
for (int i = 0; i < MEASURE; i++) {
|
||||||
|
String text = PAYLOADS[i % PAYLOADS.length];
|
||||||
|
String id = "lat-" + i;
|
||||||
|
|
||||||
|
long t0 = System.nanoTime();
|
||||||
|
String masked = pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||||
|
maskNanos[i] = System.nanoTime() - t0;
|
||||||
|
|
||||||
|
long t1 = System.nanoTime();
|
||||||
|
pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||||
|
unmaskNanos[i] = System.nanoTime() - t1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Arrays.sort(maskNanos);
|
||||||
|
Arrays.sort(unmaskNanos);
|
||||||
|
|
||||||
|
double maskUs = nanosToMicros(maskNanos);
|
||||||
|
double unmaskUs = nanosToMicros(unmaskNanos);
|
||||||
|
|
||||||
|
System.out.printf("%n=== Задержка одиночного обращения (правила, без модели) ===%n");
|
||||||
|
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||||
|
maskNanos[MEASURE / 2] / 1000.0, maskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
||||||
|
maskNanos[(int) (MEASURE * 0.99)] / 1000.0, maskUs);
|
||||||
|
System.out.printf("Демаскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||||
|
unmaskNanos[MEASURE / 2] / 1000.0, unmaskNanos[(int) (MEASURE * 0.95)] / 1000.0,
|
||||||
|
unmaskNanos[(int) (MEASURE * 0.99)] / 1000.0, unmaskUs);
|
||||||
|
|
||||||
|
// Целевая задержка из ТЗ — 200 мс; типовое обращение должно укладываться в миллисекунды.
|
||||||
|
assertTrue(maskNanos[(int) (MEASURE * 0.99)] < 5_000_000,
|
||||||
|
"p99 маскирования превысил 5 мс: " + maskNanos[(int) (MEASURE * 0.99)] / 1_000_000 + " мс");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пропускная способность под нагрузкой: сколько обращений в секунду выдерживает пайплайн. */
|
||||||
|
@Test
|
||||||
|
void throughputUnderLoad() throws Exception {
|
||||||
|
warmup();
|
||||||
|
|
||||||
|
int threads = Math.max(4, Runtime.getRuntime().availableProcessors());
|
||||||
|
int perThread = 10_000;
|
||||||
|
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||||
|
|
||||||
|
long started = System.nanoTime();
|
||||||
|
List<Future<Long>> futures = new ArrayList<>();
|
||||||
|
for (int t = 0; t < threads; t++) {
|
||||||
|
final int threadId = t;
|
||||||
|
futures.add(pool.submit((Callable<Long>) () -> {
|
||||||
|
long local = 0;
|
||||||
|
for (int i = 0; i < perThread; i++) {
|
||||||
|
String text = PAYLOADS[(threadId * 31 + i) % PAYLOADS.length];
|
||||||
|
String id = "load-" + threadId + "-" + i;
|
||||||
|
long t0 = System.nanoTime();
|
||||||
|
pipeline.process(text, id, SystemPolicy.DEFAULT);
|
||||||
|
local += System.nanoTime() - t0;
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
long totalNanos = 0;
|
||||||
|
for (Future<Long> f : futures) {
|
||||||
|
totalNanos += f.get();
|
||||||
|
}
|
||||||
|
long wallNanos = System.nanoTime() - started;
|
||||||
|
pool.shutdown();
|
||||||
|
pool.awaitTermination(30, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
int requests = threads * perThread;
|
||||||
|
double rps = requests / (wallNanos / 1e9);
|
||||||
|
double avgUs = totalNanos / (double) requests / 1000.0;
|
||||||
|
|
||||||
|
System.out.printf("%n=== Пропускная способность (%d потоков, %d обращений) ===%n", threads, requests);
|
||||||
|
System.out.printf("RPS: %.0f обращений/с средняя задержка: %.1f мкс%n", rps, avgUs);
|
||||||
|
|
||||||
|
assertTrue(rps > 1000, "пропускная способность ниже 1000 RPS: " + rps);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double nanosToMicros(long[] nanos) {
|
||||||
|
long sum = 0;
|
||||||
|
for (long n : nanos) {
|
||||||
|
sum += n;
|
||||||
|
}
|
||||||
|
return sum / (double) nanos.length / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Задержка со второй ступенью (ruBERT). Модель должна быть собрана. */
|
||||||
|
@Test
|
||||||
|
void singleRequestLatencyWithNameCascade() {
|
||||||
|
Path model = Path.of("models/rubert-ner");
|
||||||
|
assumeTrue(Files.isReadable(model), "модель " + model.toAbsolutePath() + " не собрана");
|
||||||
|
|
||||||
|
MeterRegistry meters = new SimpleMeterRegistry();
|
||||||
|
Pipeline withCascade = new Pipeline(new RuleRegistry(), new Masker(),
|
||||||
|
new PayloadStore(10_000_000L, 30),
|
||||||
|
new NameCascade("rubert", model.toString(), 16, 4, meters));
|
||||||
|
|
||||||
|
// Прогрев второй ступени: модель инициализируется лениво, первые вызовы медленные.
|
||||||
|
for (int i = 0; i < 200; i++) {
|
||||||
|
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
||||||
|
withCascade.process(text, "cascade-warmup-" + i, SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
long[] maskNanos = new long[2000];
|
||||||
|
for (int i = 0; i < 2000; i++) {
|
||||||
|
String text = CASCADE_PAYLOADS[i % CASCADE_PAYLOADS.length];
|
||||||
|
String id = "cascade-lat-" + i;
|
||||||
|
long t0 = System.nanoTime();
|
||||||
|
withCascade.process(text, id, SystemPolicy.DEFAULT);
|
||||||
|
maskNanos[i] = System.nanoTime() - t0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Arrays.sort(maskNanos);
|
||||||
|
int n = maskNanos.length;
|
||||||
|
System.out.printf("%n=== Задержка одиночного обращения со второй ступенью (ruBERT) ===%n");
|
||||||
|
System.out.printf("Маскирование: p50=%.1f мкс p95=%.1f мкс p99=%.1f мкс среднее=%.1f мкс%n",
|
||||||
|
maskNanos[n / 2] / 1000.0, maskNanos[(int) (n * 0.95)] / 1000.0,
|
||||||
|
maskNanos[(int) (n * 0.99)] / 1000.0, nanosToMicros(maskNanos));
|
||||||
|
|
||||||
|
double engaged = meters.counter("pdguard.ner.requests", "outcome", "engaged").count();
|
||||||
|
double candidates = meters.counter("pdguard.ner.candidates").count();
|
||||||
|
System.out.printf("Обращений к модели: %.0f, кандидатов разобрано: %.0f%n", engaged, candidates);
|
||||||
|
|
||||||
|
// Целевая задержка из ТЗ — 200 мс; даже со второй ступенью типовое обращение
|
||||||
|
// должно укладываться в десятки миллисекунд.
|
||||||
|
assertTrue(maskNanos[(int) (n * 0.99)] < 200_000_000,
|
||||||
|
"p99 маскирования со второй ступенью превысил 200 мс: "
|
||||||
|
+ maskNanos[(int) (n * 0.99)] / 1_000_000 + " мс");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Проверки маскирования и обратного преобразования без подъёма HTTP-слоя. */
|
||||||
|
class PipelineTest {
|
||||||
|
|
||||||
|
private static final String VALID_CARD = "4111 1111 1111 1111";
|
||||||
|
private static final String VALID_INN_12 = "770301234550";
|
||||||
|
private static final String VALID_SNILS = "112-233-445 95";
|
||||||
|
|
||||||
|
private Pipeline pipeline() {
|
||||||
|
return new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mask(Pipeline pipeline, String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCardNumber() {
|
||||||
|
String masked = mask(pipeline(), "Оплата картой " + VALID_CARD + " прошла");
|
||||||
|
assertFalse(masked.contains(VALID_CARD), "номер карты остался в тексте: " + masked);
|
||||||
|
assertTrue(masked.contains("41** **** **** **11"), masked);
|
||||||
|
assertTrue(masked.startsWith("Оплата картой "), "окружающий текст изменён: " + masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsNumberThatFailsLuhn() {
|
||||||
|
String text = "Заказ 1234 5678 9012 3456 отгружен";
|
||||||
|
assertEquals(text, mask(pipeline(), text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksEmailKeepingTopLevelDomain() {
|
||||||
|
String masked = mask(pipeline(), "Почта ivan.petrov@mail.ru для связи");
|
||||||
|
assertEquals("Почта i**********@m***.ru для связи", masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksPhoneInAnyNotation() {
|
||||||
|
Pipeline pipeline = pipeline();
|
||||||
|
for (String phone : new String[]{"+7 (916) 123-45-67", "89161234567", "8 916 123 45 67"}) {
|
||||||
|
String masked = mask(pipeline, "Телефон " + phone);
|
||||||
|
assertFalse(masked.contains(phone), "телефон остался в тексте: " + masked);
|
||||||
|
assertTrue(masked.endsWith("67"), masked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksInnByContextAndByChecksum() {
|
||||||
|
Pipeline pipeline = pipeline();
|
||||||
|
assertFalse(mask(pipeline, "ИНН: " + VALID_INN_12).contains(VALID_INN_12));
|
||||||
|
assertFalse(mask(pipeline, "Реквизиты " + VALID_INN_12 + " проверены").contains(VALID_INN_12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksSnils() {
|
||||||
|
String masked = mask(pipeline(), "СНИЛС " + VALID_SNILS);
|
||||||
|
assertFalse(masked.contains(VALID_SNILS), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmaskingRestoresOriginalText() {
|
||||||
|
Pipeline pipeline = pipeline();
|
||||||
|
String original = "Карта " + VALID_CARD + ", почта ivan@mail.ru, телефон +7 916 123-45-67";
|
||||||
|
String id = "pair-1";
|
||||||
|
|
||||||
|
String masked = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
|
assertNotEquals(original, masked);
|
||||||
|
|
||||||
|
String restored = pipeline.process(masked, id, SystemPolicy.DEFAULT);
|
||||||
|
assertEquals(original, restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryReturnsSameMask() {
|
||||||
|
Pipeline pipeline = pipeline();
|
||||||
|
String original = "Карта " + VALID_CARD;
|
||||||
|
String id = "retry-1";
|
||||||
|
|
||||||
|
String first = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
|
String second = pipeline.process(original, id, SystemPolicy.DEFAULT);
|
||||||
|
assertEquals(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmasksWhenPayloadIdIsUnknown() {
|
||||||
|
Pipeline pipeline = pipeline();
|
||||||
|
String original = "Почта ivan@mail.ru";
|
||||||
|
String masked = pipeline.process(original, "lost-id", SystemPolicy.DEFAULT);
|
||||||
|
|
||||||
|
assertEquals(original, pipeline.process(masked, "другой-идентификатор", SystemPolicy.DEFAULT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void textWithoutPersonalDataIsUnchanged() {
|
||||||
|
String text = "Расскажи о погоде в Москве завтра";
|
||||||
|
assertEquals(text, mask(pipeline(), text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void systemPolicyDisablesSelectedTypes() {
|
||||||
|
Pipeline pipeline = pipeline();
|
||||||
|
SystemPolicy onlyEmail = SystemPolicy.forTypes(RuleRegistry.EMAIL);
|
||||||
|
String masked = pipeline.process("Карта " + VALID_CARD + ", почта ivan@mail.ru", "policy-1", onlyEmail);
|
||||||
|
|
||||||
|
assertTrue(masked.contains(VALID_CARD), "карта не должна маскироваться этой системой: " + masked);
|
||||||
|
assertFalse(masked.contains("ivan@mail.ru"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handlesLargeText() {
|
||||||
|
Pipeline pipeline = pipeline();
|
||||||
|
String block = "Клиент написал с адреса ivan@mail.ru и оплатил картой " + VALID_CARD + ". ";
|
||||||
|
String large = block.repeat(4000);
|
||||||
|
|
||||||
|
long started = System.nanoTime();
|
||||||
|
String masked = pipeline.process(large, "large-1", SystemPolicy.DEFAULT);
|
||||||
|
long millis = (System.nanoTime() - started) / 1_000_000;
|
||||||
|
|
||||||
|
assertFalse(masked.contains("ivan@mail.ru"));
|
||||||
|
assertEquals(large, pipeline.process(masked, "large-1", SystemPolicy.DEFAULT));
|
||||||
|
assertTrue(millis < 1000, "обработка крупного текста заняла " + millis + " мс");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
|
import static org.hamcrest.Matchers.not;
|
||||||
|
|
||||||
|
/** Проверка контракта: форма запроса и ответа, пара «маскирование — демаскирование». */
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
class ProcessResourceTest {
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
int port;
|
||||||
|
|
||||||
|
private String post(String payload, String payloadId) {
|
||||||
|
return given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", payload, "payload_id", payloadId))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(200)
|
||||||
|
.extract().path("result");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void maskAndUnmaskPair() {
|
||||||
|
String original = "Клиент оставил почту ivan.petrov@mail.ru и телефон +7 916 123-45-67";
|
||||||
|
String id = "8a77d363c7c044b49b41d7b8a448243a";
|
||||||
|
|
||||||
|
String masked = post(original, id);
|
||||||
|
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(original, post(masked, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsRequestWithoutRequiredFields() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", "текст"))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void healthProbeResponds() {
|
||||||
|
given().port(port).when().get("/health").then().statusCode(200).body(equalTo("OK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledSystemIsRefused() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "disabled")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "sys-1"))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void systemPolicySelectsMaskMode() {
|
||||||
|
String masked = given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "crm")
|
||||||
|
.body(Map.of("payload", "Клиент Иванов Иван Иванович", "payload_id", "sys-2"))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(200)
|
||||||
|
.extract().path("result");
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(masked.contains("[FIO_1]"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownSystemFallsBackToDefaultPolicy() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "неизвестная-система")
|
||||||
|
.body(Map.of("payload", "почта ivan@mail.ru", "payload_id", "sys-3"))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(200)
|
||||||
|
.body("result", equalTo("почта i***@m***.ru"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metricsExposeLatencyAndTokenCounters() {
|
||||||
|
post("Клиент Иванов Иван Иванович", "metrics-1");
|
||||||
|
|
||||||
|
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_process_seconds"), "нет метрики задержки");
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_tokens_processed_total"), "нет метрики TPS");
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_pd_detected_total"), "нет метрики типов ПД");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Соответствия разделены по системам. Маски детерминированы и низкоэнтропийны,
|
||||||
|
* поэтому без разделения, прислав чужую маску, можно было бы получить исходные
|
||||||
|
* данные другого потребителя.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void anotherSystemCannotExchangeMaskForOriginal() {
|
||||||
|
String original = "Клиент Иванов Иван Иванович, паспорт 4509 123456";
|
||||||
|
String id = "cross-system-1";
|
||||||
|
|
||||||
|
String masked = given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", original, "payload_id", id))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(200)
|
||||||
|
.extract().path("result");
|
||||||
|
org.junit.jupiter.api.Assertions.assertNotEquals(original, masked);
|
||||||
|
|
||||||
|
String byOther = given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.header("X-System-Id", "other")
|
||||||
|
.body(Map.of("payload", masked, "payload_id", "совсем-другой-id"))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(200)
|
||||||
|
.extract().path("result");
|
||||||
|
org.junit.jupiter.api.Assertions.assertNotEquals(original, byOther,
|
||||||
|
"чужая система не должна получать исходный текст по маске");
|
||||||
|
|
||||||
|
String bySameSystem = given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", masked, "payload_id", id))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(200)
|
||||||
|
.extract().path("result");
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(original, bySameSystem,
|
||||||
|
"своя система по своему идентификатору исходный текст получает");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void systemWithKeyRequiresIt() {
|
||||||
|
given().port(port).contentType("application/json")
|
||||||
|
.header("X-System-Id", "guarded")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-1"))
|
||||||
|
.when().post("/process").then().statusCode(403);
|
||||||
|
|
||||||
|
given().port(port).contentType("application/json")
|
||||||
|
.header("X-System-Id", "guarded")
|
||||||
|
.header("X-System-Key", "wrong-key")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-2"))
|
||||||
|
.when().post("/process").then().statusCode(403);
|
||||||
|
|
||||||
|
given().port(port).contentType("application/json")
|
||||||
|
.header("X-System-Id", "guarded")
|
||||||
|
.header("X-System-Key", "s3cret-key-2026")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-3"))
|
||||||
|
.when().post("/process").then().statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void systemWithoutKeyWorksWithoutIt() {
|
||||||
|
given().port(port).contentType("application/json")
|
||||||
|
.body(Map.of("payload", "Карта 4111 1111 1111 1111", "payload_id", "key-4"))
|
||||||
|
.when().post("/process").then().statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metricsCountSecondStageInvocations() {
|
||||||
|
post("Клиент Иванов Иван Иванович", "ner-metrics-1");
|
||||||
|
|
||||||
|
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_requests_total"),
|
||||||
|
"нет счётчика обращений ко второй ступени");
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(body.contains("pdguard_ner_candidates_total"),
|
||||||
|
"нет счётчика участков, отданных модели");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metricsDoNotLeakPersonalData() {
|
||||||
|
post("Клиент Иванов Иван Иванович, карта 4111 1111 1111 1111", "metrics-2");
|
||||||
|
|
||||||
|
String body = given().port(port).when().get("/actuator/prometheus").then().statusCode(200).extract().asString();
|
||||||
|
// Значения метрик — это числа, и цифры из ПД могут случайно совпасть с ними.
|
||||||
|
// Утечка возможна только через имена и метки, поэтому значения отбрасываем.
|
||||||
|
String namesAndLabels = body.lines()
|
||||||
|
.filter(line -> !line.startsWith("#"))
|
||||||
|
.map(line -> line.contains(" ") ? line.substring(0, line.lastIndexOf(' ')) : line)
|
||||||
|
.reduce("", (a, b) -> a + "\n" + b);
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("Иванов"), "ПД попали в метрики");
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(namesAndLabels.contains("4111"), "ПД попали в метрики");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminShowsSystemsAndTypes() {
|
||||||
|
given().port(port).when().get("/admin/config").then().statusCode(200).body("crm.maskMode", equalTo("TOKEN"));
|
||||||
|
given().port(port).when().get("/admin/types").then().statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void textWithoutPersonalDataIsReturnedAsIs() {
|
||||||
|
given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("payload", "тестовая строка", "payload_id", "selfcheck-1"))
|
||||||
|
.when().post("/process")
|
||||||
|
.then().statusCode(200)
|
||||||
|
.body("result", equalTo("тестовая строка"))
|
||||||
|
.body("result", not(equalTo("")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import io.restassured.path.json.JsonPath;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Демонстрационное плечо: потребитель → маскирование → LLM → демаскирование. */
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
class ProxyResourceTest {
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
int port;
|
||||||
|
|
||||||
|
private static final String PROMPT =
|
||||||
|
"Составь письмо клиенту Иванову Ивану Ивановичу, паспорт 4509 123456, почта ivan@mail.ru";
|
||||||
|
|
||||||
|
private JsonPath proxy(String prompt) {
|
||||||
|
return given()
|
||||||
|
.port(port)
|
||||||
|
.contentType("application/json")
|
||||||
|
.body(Map.of("prompt", prompt))
|
||||||
|
.when().post("/proxy")
|
||||||
|
.then().statusCode(200)
|
||||||
|
.extract().jsonPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void personalDataDoesNotReachTheModel() {
|
||||||
|
JsonPath json = proxy(PROMPT);
|
||||||
|
String toModel = json.getString("prompt_masked");
|
||||||
|
|
||||||
|
assertFalse(toModel.contains("Иванову Ивану Ивановичу"), toModel);
|
||||||
|
assertFalse(toModel.contains("4509 123456"), toModel);
|
||||||
|
assertFalse(toModel.contains("ivan@mail.ru"), toModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumerGetsTheAnswerWithOriginalValues() {
|
||||||
|
JsonPath json = proxy(PROMPT);
|
||||||
|
String answer = json.getString("response");
|
||||||
|
|
||||||
|
assertTrue(answer.contains("Иванову Ивану Ивановичу"), answer);
|
||||||
|
assertTrue(answer.contains("4509 123456"), answer);
|
||||||
|
assertTrue(answer.contains("ivan@mail.ru"), answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void responseShowsTheWholeChain() {
|
||||||
|
JsonPath json = proxy(PROMPT);
|
||||||
|
|
||||||
|
assertTrue(json.getString("prompt_masked").contains("[FIO_1]"),
|
||||||
|
"в модель уходит обратимая подстановка: " + json.getString("prompt_masked"));
|
||||||
|
assertTrue(json.getString("llm_response_masked").contains("[FIO_1]"),
|
||||||
|
"ответ модели ещё содержит подстановки");
|
||||||
|
assertFalse(json.getString("response").contains("[FIO_1]"),
|
||||||
|
"потребителю подстановки не видны");
|
||||||
|
assertEquals("заглушка", json.getString("llm"));
|
||||||
|
assertFalse(json.getMap("replaced").isEmpty(), "таблица замен не должна быть пустой");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void textWithoutPersonalDataPassesThrough() {
|
||||||
|
JsonPath json = proxy("Объясни разницу между вкладом и накопительным счётом");
|
||||||
|
assertEquals("Объясни разницу между вкладом и накопительным счётом", json.getString("prompt_masked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEmptyPrompt() {
|
||||||
|
given().port(port).contentType("application/json").body(Map.of("prompt", " "))
|
||||||
|
.when().post("/proxy").then().statusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledSystemIsRefused() {
|
||||||
|
given().port(port).contentType("application/json")
|
||||||
|
.header("X-System-Id", "disabled")
|
||||||
|
.body(Map.of("prompt", PROMPT))
|
||||||
|
.when().post("/proxy").then().statusCode(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Адрес не только в городе: правило {@code ADDRESS_CITY} расширено якорями
|
||||||
|
* на сёла, посёлки, деревни, хутора, станицы, аулы и аалы — раньше словарь
|
||||||
|
* ограничивался официальными городами (~1100), и «рп. Ильинское»/«с. Кукуево»
|
||||||
|
* из ТЗ не находились вообще, дело было не в качестве детекции, а в том, что
|
||||||
|
* искать было негде.
|
||||||
|
*/
|
||||||
|
class SettlementTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private String mask(String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertHidden(String text, String secret) {
|
||||||
|
String masked = mask(text);
|
||||||
|
assertFalse(masked.contains(secret), "не замаскировано: «" + secret + "» в ответе «" + masked + "»");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksVillage() {
|
||||||
|
assertHidden("Клиент проживает в с. Кукуево постоянно", "Кукуево");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksWorkersSettlement() {
|
||||||
|
assertHidden("Регистрация по адресу: рп. Ильинское", "Ильинское");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksUrbanTypeSettlement() {
|
||||||
|
assertHidden("Доставка курьером в пгт. Энем", "Энем");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksHamlet() {
|
||||||
|
assertHidden("Дом находится в д. Аксеновка", "Аксеновка");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksFarmstead() {
|
||||||
|
assertHidden("Клиент родом из х. Прогресс", "Прогресс");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksCossackStanitsa() {
|
||||||
|
assertHidden("Прописан в ст-ца Гиагинская", "Гиагинская");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksAul() {
|
||||||
|
assertHidden("Живёт в аул Блечепсин", "Блечепсин");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksSloboda() {
|
||||||
|
assertHidden("Проживает в сл. Екатериновка", "Екатериновка");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void masksAal() {
|
||||||
|
assertHidden("Зарегистрирован в аал Сартыков", "Сартыков");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Словарь не подтверждает выдуманное название — якорь один в один, а маски нет. */
|
||||||
|
@Test
|
||||||
|
void doesNotMaskFictionalPlaceNameDespiteRealAnchor() {
|
||||||
|
String text = "Клиент проживает в с. Мнимогорск";
|
||||||
|
assertEquals(text, mask(text), "выдуманное название не должно проходить словарь");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вето по организационному контексту работает и для новых типов НП, не только для городов. */
|
||||||
|
@Test
|
||||||
|
void doesNotMaskOrganisationAddressInVillage() {
|
||||||
|
String text = "Ближайшее отделение банка находится в с. Кукуево";
|
||||||
|
assertEquals(text, mask(text), "адрес отделения банка не является ПД клиента");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anchorsAreCaseInsensitive() {
|
||||||
|
assertHidden("клиент проживает в С. КУКУЕВО", "КУКУЕВО");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Реалистичное предложение: населённый пункт, улица и дом вместе. */
|
||||||
|
@Test
|
||||||
|
void masksSettlementStreetAndHouseTogether() {
|
||||||
|
String masked = mask("Проживает по адресу: д. Аксеновка, ул. Садовая, д. 7");
|
||||||
|
assertFalse(masked.contains("Аксеновка"), masked);
|
||||||
|
assertFalse(masked.contains("Садовая"), masked);
|
||||||
|
assertFalse(masked.contains("д. 7"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «с.» перед числом — обычная запись страницы («с. 25»), а не населённого
|
||||||
|
* пункта. Якорь не должен на этом срабатывать: правило требует заглавную
|
||||||
|
* букву сразу после якоря, а не цифру.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void pageReferenceIsNotMistakenForSettlement() {
|
||||||
|
String text = "См. с. 25 договора";
|
||||||
|
assertEquals(text, mask(text), "номер страницы не должен приниматься за населённый пункт");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.core.PayloadStore;
|
||||||
|
import ru.pdguard.core.Pipeline;
|
||||||
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Составные названия улиц в честь людей — тот же класс ложных срабатываний,
|
||||||
|
* что и «Богдана Хмельницкого» на реальных адресах Альфа-Банка: правило ФИО
|
||||||
|
* без ролевого слова ловит «имя + фамилия по словообразованию», а улица в
|
||||||
|
* честь исторической фигуры выглядит точно так же. Одиночная фамилия («улица
|
||||||
|
* Ленина») под это правило не подпадает вообще — ему нужны два слова, поэтому
|
||||||
|
* все примеры здесь двухсловные, реальные названия улиц.
|
||||||
|
*/
|
||||||
|
class StreetDenylistTest {
|
||||||
|
|
||||||
|
private final Pipeline pipeline =
|
||||||
|
new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1_000_000L, 30));
|
||||||
|
|
||||||
|
private String mask(String text) {
|
||||||
|
return pipeline.process(text, UUID.randomUUID().toString(), SystemPolicy.DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertUnmasked(String text) {
|
||||||
|
assertEquals(text, mask(text), "улица в честь исторической фигуры не должна маскироваться: " + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskNikolaiOstrovskyStreet() {
|
||||||
|
assertUnmasked("Живу на улице Николая Островского уже десять лет");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskAlexanderMatrosovStreet() {
|
||||||
|
assertUnmasked("Магазин находится на улице Александра Матросова");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskValeryChkalovStreet() {
|
||||||
|
assertUnmasked("Заезжайте на улицу Валерия Чкалова");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskVeraVoloshinaStreet() {
|
||||||
|
assertUnmasked("Новый дом построили на улице Веры Волошиной");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskIvanSusaninStreet() {
|
||||||
|
assertUnmasked("Школа расположена на улице Ивана Сусанина");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskSergeyKirovStreet() {
|
||||||
|
assertUnmasked("Парковка есть на улице Сергея Кирова");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskGeorgiDimitrovStreet() {
|
||||||
|
assertUnmasked("Остановка на улице Георгия Димитрова");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тот же принцип, что и у Пушкина: рядом с другим ПД денилист не
|
||||||
|
* применяется — если в тексте всё-таки есть настоящие персональные данные,
|
||||||
|
* совпадение с историческим именем их не прикрывает.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void masksCommemorativeStreetNameWhenOtherPersonalDataIsPresent() {
|
||||||
|
String masked = mask("Живу на улице Николая Островского, тел. +7 916 123-45-67");
|
||||||
|
assertFalse(masked.contains("Николая Островского"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Контроль: обычное клиентское имя того же грамматического вида всё ещё маскируется. */
|
||||||
|
@Test
|
||||||
|
void stillMasksRealClientNameWithSimilarPattern() {
|
||||||
|
String masked = mask("Живёт на улице, зовут Николая Смирнова");
|
||||||
|
assertFalse(masked.contains("Николая Смирнова"), masked);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotMaskDmitryDonskoyStreet() {
|
||||||
|
assertUnmasked("Дом стоит на улице Дмитрия Донского");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import ru.pdguard.config.SystemPolicy;
|
||||||
|
import ru.pdguard.config.SystemsConfig;
|
||||||
|
import ru.pdguard.mask.MaskMode;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Чтение и горячая перезагрузка списка систем. */
|
||||||
|
class SystemsConfigTest {
|
||||||
|
|
||||||
|
private static final String CONTENT = """
|
||||||
|
{
|
||||||
|
"default": { "enabled": true, "demask": true, "maskMode": "MASK", "types": ["*"] },
|
||||||
|
"crm": { "enabled": true, "demask": false, "maskMode": "TOKEN", "types": ["FIO"] },
|
||||||
|
"old": { "enabled": false }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private SystemsConfig configAt(Path file) {
|
||||||
|
return new SystemsConfig(file.toString(), new ObjectMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsPoliciesFromFile(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
SystemsConfig config = configAt(file);
|
||||||
|
SystemPolicy crm = config.policyFor("crm");
|
||||||
|
|
||||||
|
assertEquals(MaskMode.TOKEN, crm.maskMode());
|
||||||
|
assertFalse(crm.demask());
|
||||||
|
assertTrue(crm.allows("FIO"));
|
||||||
|
assertFalse(crm.allows("CARD"));
|
||||||
|
assertFalse(config.policyFor("old").enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownSystemGetsDefaultPolicy(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
SystemPolicy policy = configAt(file).policyFor("никому-не-известная");
|
||||||
|
assertTrue(policy.enabled());
|
||||||
|
assertTrue(policy.allows("CARD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void worksWithoutConfigFile(@TempDir Path dir) {
|
||||||
|
SystemsConfig config = configAt(dir.resolve("нет-такого-файла.json"));
|
||||||
|
assertEquals(SystemPolicy.DEFAULT, config.policyFor("любая"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picksUpChangesWithoutRestart(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||||
|
SystemsConfig config = configAt(file);
|
||||||
|
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode());
|
||||||
|
|
||||||
|
Files.writeString(file, CONTENT.replace("\"TOKEN\"", "\"SYNTHETIC\""), StandardCharsets.UTF_8);
|
||||||
|
config.reload();
|
||||||
|
|
||||||
|
assertEquals(MaskMode.SYNTHETIC, config.policyFor("crm").maskMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void brokenFileKeepsPreviousSettings(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("systems.json");
|
||||||
|
Files.writeString(file, CONTENT, StandardCharsets.UTF_8);
|
||||||
|
SystemsConfig config = configAt(file);
|
||||||
|
|
||||||
|
Files.writeString(file, "{ это не json", StandardCharsets.UTF_8);
|
||||||
|
config.reload();
|
||||||
|
|
||||||
|
assertEquals(MaskMode.TOKEN, config.policyFor("crm").maskMode(),
|
||||||
|
"сломанный файл не должен ронять работающий сервис");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Денилист известных людей: встроенный список и дозагрузка сверху без пересборки. */
|
||||||
|
class NameDictionaryTest {
|
||||||
|
|
||||||
|
/** Возвращает состояние словаря к встроенному списку — не течёт в остальные тесты. */
|
||||||
|
@AfterEach
|
||||||
|
void resetToBundledList() {
|
||||||
|
NameDictionary.useExternalFile(Path.of("config/well-known.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bundledListCoversClassicFigures() {
|
||||||
|
// Сравнение по началу слова покрывает обычную русскую флексию («Пушкин» →
|
||||||
|
// «Пушкина»), но не прилагательное склонение («Толстой» → «Толстого», где
|
||||||
|
// «-ой» меняется на «-ого» целиком, а не дописывается) — известное
|
||||||
|
// ограничение самого приёма, не завязанное на список имён.
|
||||||
|
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
||||||
|
assertTrue(NameDictionary.isWellKnown("Портрет Толстой"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bundledListCoversCurrentPublicFigures() {
|
||||||
|
assertTrue(NameDictionary.isWellKnown("Заявление Набиуллиной"));
|
||||||
|
assertTrue(NameDictionary.isWellKnown("Интервью Путина"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownSurnameIsNotWellKnown() {
|
||||||
|
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фамилии, в честь которых чаще всего называют улицы в России (Росреестр).
|
||||||
|
* Само по себе «улица Ленина» никогда не попало бы под ФИО — для этого
|
||||||
|
* правила нужны два слова, — но денилист должен покрывать и составные
|
||||||
|
* названия («Феликса Дзержинского»), и вариации написания («Будённый»/
|
||||||
|
* «Буденный»).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void bundledListCoversCommemorativeStreetNames() {
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Кирова"));
|
||||||
|
// «-ский» склоняется целиком («Дзержинский» → «Дзержинского»), для таких
|
||||||
|
// основа обрезается сразу до «ск» — см. Declension.
|
||||||
|
assertTrue(NameDictionary.isWellKnown("проспект Дзержинского"));
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Донского"));
|
||||||
|
// «Будённый» — чистое прилагательное без «-ский» (как «Толстой»): та же
|
||||||
|
// известная граница приёма, родительный падеж («Будённого») им не ловится.
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Будённый"));
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Буденный"), "написание без «ё» тоже должно ловиться");
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Жукова"));
|
||||||
|
assertTrue(NameDictionary.isWellKnown("улица Островского"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void externalFileAddsNamesWithoutRebuild(@TempDir Path dir) throws IOException {
|
||||||
|
Path file = dir.resolve("well-known.txt");
|
||||||
|
Files.writeString(file, "Кастомов\n", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
NameDictionary.useExternalFile(file);
|
||||||
|
|
||||||
|
assertTrue(NameDictionary.isWellKnown("Интервью Кастомова"),
|
||||||
|
"дописанное сверху имя должно распознаваться наравне со встроенными");
|
||||||
|
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"),
|
||||||
|
"встроенный список не должен теряться при дозагрузке");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingExternalFileFallsBackToBundledListOnly(@TempDir Path dir) {
|
||||||
|
NameDictionary.useExternalFile(dir.resolve("нет-такого-файла.txt"));
|
||||||
|
|
||||||
|
assertTrue(NameDictionary.isWellKnown("Стихи Пушкина"));
|
||||||
|
assertFalse(NameDictionary.isWellKnown("Заявление Смирнова"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Словарь населённых пунктов — не только города, но и сёла, посёлки, деревни, хутора. */
|
||||||
|
class ToponymDictionaryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesNominativeCase() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Москва"));
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Казань"));
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Санкт-Петербург"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesInflectedForms() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Москве"), "дательный падеж города на гласную");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Тамбове"), "предложный падеж города на согласную");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Казани"), "родительный падеж");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recognisesCisCapitals() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Минск"));
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Алматы"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMadeUpWord() {
|
||||||
|
assertFalse(ToponymDictionary.isKnownSettlement("Ерунда"));
|
||||||
|
assertFalse(ToponymDictionary.isKnownSettlement("Бла-бла"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isCaseInsensitive() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("МОСКВА"));
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("москва"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Из переписи 2020–2021, не из ручного списка городов: сёла, посёлки,
|
||||||
|
* деревни, хутора, станицы, аулы, аалы — ровно то, чего не было, пока
|
||||||
|
* словарь ограничивался официальными городами. Примеры из ТЗ («рп.
|
||||||
|
* Ильинское», «с. Кукуево») и по одному реальному названию на тип
|
||||||
|
* населённого пункта.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void recognisesSettlementsFromCensusNotJustOfficialCities() {
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Ильинское"), "рп. Ильинское — пример из ТЗ");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Кукуево"), "с. Кукуево — пример из ТЗ");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Прогресс"), "хутор");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Гиагинская"), "станица");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Блечепсин"), "аул");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Энем"), "посёлок городского типа");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Аксеновка"), "деревня");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Екатериновка"), "слобода");
|
||||||
|
assertTrue(ToponymDictionary.isKnownSettlement("Сартыков"), "аал (Хакасия)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Контрольные суммы ОГРН/ОГРНИП: первые 12/14 цифр по модулю 11/13, младший разряд остатка. */
|
||||||
|
class ValidatorsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validOgrnPassesChecksum() {
|
||||||
|
assertTrue(Validators.ogrn("1027700123450"));
|
||||||
|
assertTrue(Validators.ogrn("1025000678900"));
|
||||||
|
assertTrue(Validators.ogrn("1045002233440"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidOgrnChecksumFails() {
|
||||||
|
assertFalse(Validators.ogrn("1027700123451"), "последняя цифра изменена — сумма не сходится");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ogrnWrongLengthFails() {
|
||||||
|
assertFalse(Validators.ogrn("102770012345"), "12 цифр — не хватает контрольной");
|
||||||
|
assertFalse(Validators.ogrn("10277001234500"), "14 цифр — лишняя");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Остаток от деления на 11 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||||
|
@Test
|
||||||
|
void ogrnRemainderTenMapsToZero() {
|
||||||
|
assertTrue(Validators.ogrn("1000000000000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validOgrnipPassesChecksum() {
|
||||||
|
assertTrue(Validators.ogrnip("304500116000157"));
|
||||||
|
assertTrue(Validators.ogrnip("312500000000013"));
|
||||||
|
assertTrue(Validators.ogrnip("305500112233041"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidOgrnipChecksumFails() {
|
||||||
|
assertFalse(Validators.ogrnip("304500116000158"), "последняя цифра изменена — сумма не сходится");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ogrnipWrongLengthFails() {
|
||||||
|
assertFalse(Validators.ogrnip("30450011600015"), "14 цифр — не хватает контрольной");
|
||||||
|
assertFalse(Validators.ogrnip("30450011600015700"), "18 цифр — лишние");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Остаток от деления на 13 может быть 10 — тогда контрольная цифра 0, не 10. */
|
||||||
|
@Test
|
||||||
|
void ogrnipRemainderTenMapsToZero() {
|
||||||
|
assertTrue(Validators.ogrnip("100000000000000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonDigitCharactersAreIgnored() {
|
||||||
|
assertTrue(Validators.ogrn("10-27700-123450"), "разделители в номере не мешают счёту цифр");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyValueIsNotValid() {
|
||||||
|
assertFalse(Validators.ogrn(""));
|
||||||
|
assertFalse(Validators.ogrnip(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,981 @@
|
|||||||
|
105037, Г. МОСКВА, ИЗМАЙЛОВСКИЙ ПРОЕЗД, Д. 8А
|
||||||
|
105043, Г. МОСКВА, УЛ. 9-Я ПАРКОВАЯ, Д. 23
|
||||||
|
105094, Г. МОСКВА, УЛ. БОЛЬШАЯ СЕМЕНОВСКАЯ, Д. 42, СТР. 4
|
||||||
|
105120, Г. МОСКВА, УЛ. ЗЕМЛЯНОЙ ВАЛ, Д. 41, СТР. 1
|
||||||
|
105120, РФ, Г. МОСКВА, УЛ. ЗЕМЛЯНОЙ ВАЛ, Д. 41, СТР. 1, ПОМ. 1 (12,54 КВ.М.)
|
||||||
|
105318, ГОРОД МОСКВА, УЛ. ВЕЛЬЯМИНОВСКАЯ, Д. 6
|
||||||
|
107006, Г. МОСКВА, УЛ. НИЖНЯЯ КРАСНОСЕЛЬСКАЯ, Д. 45/17
|
||||||
|
107014, Г. МОСКВА, СОКОЛЬНИЧЕСКАЯ ПЛОЩАДЬ, Д. 9, ПОМЕЩЕНИЕ 14Н
|
||||||
|
107014, Г. МОСКВА, СОКОЛЬНИЧЕСКАЯ ПЛОЩАДЬ, Д. 9, ПОМЕЩЕНИЕ 14Н, К. 1-009
|
||||||
|
107031, Г. МОСКВА, УЛ. НЕГЛИННАЯ, ДОМ 10
|
||||||
|
107066, Г. МОСКВА, УЛ. НИЖНЯЯ КРАСНОСЕЛЬСКАЯ, ДОМ 45/17
|
||||||
|
107078, Г. МОСКВА, УЛ. КАЛАНЧЕВСКАЯ, 17, СТР. 1
|
||||||
|
107078, Г. МОСКВА, УЛ. КАЛАНЧЕВСКАЯ, Д.27
|
||||||
|
107078, Г. МОСКВА, УЛ. МАШИ ПОРЫВАЕВОЙ, Д.7, СТР.Г
|
||||||
|
107497, Г. МОСКВА, УЛ. ИРКУТСКАЯ, Д. 5/6, СТР. 1
|
||||||
|
107497, Г. МОСКВА, УЛ. ИРКУТСКАЯ, ДОМ 17, СТР. 8
|
||||||
|
108801, Г. МОСКВА, СОСЕНСКОЕ ПОСЕЛЕНИЕ, П. КОММУНАРКА, УЛ. ЛИПОВЫЙ ПАРК, Д. 11, ПОМ. 19/1
|
||||||
|
108811, Г. МОСКВА, Г. МОСКОВСКИЙ, УЛ. НИКИТИНА, Д. 2
|
||||||
|
108811, Г. МОСКВА, ПОС. МОСКОВСКИЙ, КВ-Л №80, Д. 11А, СТР.1
|
||||||
|
108811, Г. МОСКВА, ПОСЕЛЕНИЕ МОСКОВСКИЙ, КВ-Л №34, ДВЛД. 2, СТР. 1
|
||||||
|
108811, Г. МОСКВА, Р-Н КОММУНАРКА, КВ-Л 80, Д.3, СТР.1
|
||||||
|
108842, ГОРОД МОСКВА, Г. ТРОИЦК, ПЛОЩАДЬ АКАДЕМИЧЕСКАЯ, Д. 5
|
||||||
|
108849, ГОРОД МОСКВА, УЛ. КОРНЕЯ ЧУКОВСКОГО, Д. 2, СТР. 1
|
||||||
|
109029, ГОРОД МОСКВА, ПР-Д МИХАЙЛОВСКИЙ, Д. 3, СТР. 3.
|
||||||
|
109044, Г. МОСКВА, 3-Й КРУТИЦКИЙ ПЕРЕУЛОК, Д. 13
|
||||||
|
109044, Г. МОСКВА, 3-Й КРУТИЦКИЙ ПЕРЕУЛОК, Д. 13, ПОМ. 1, ЧАСТЬ КОМНАТЫ №9
|
||||||
|
109145, Г. МОСКВА, ЛЕРМОНТОВСКИЙ ПРОСПЕКТ, Д. 10, КОРП. 1, ПОМ. 6
|
||||||
|
109145, Г. МОСКВА, ЛЕРМОНТОВСКИЙ ПРОСПЕКТ, Д. 10, КОРП. 1, ПОМ. IV А
|
||||||
|
109316, Г. МОСКВА, ПР-КТ ВОЛГОГРАДСКИЙ, Д.41, К.1.
|
||||||
|
109341, Г. МОСКВА, УЛ. ПЕРЕРВА, Д. 43, КОРП. 1
|
||||||
|
109386, Г. МОСКВА, УЛ. СОВХОЗНАЯ, Д. 39
|
||||||
|
109462, Г. МОСКВА, УЛ. МАРШАЛА ЧУЙКОВА, Д. 3
|
||||||
|
109462, Г. МОСКВА, УЛ. МАРШАЛА ЧУЙКОВА, Д. 3, ПОМ. 1-012
|
||||||
|
109469, ГОРОД МОСКВА, УЛ. МАРЬИНСКИЙ ПАРК, Д. 1
|
||||||
|
109544, Г. МОСКВА, БУЛЬВАР ЭНТУЗИАСТОВ, Д. 2
|
||||||
|
109652, Г. МОСКВА, УЛ. ЛЮБЛИНСКАЯ, Д. 165, КОРП. 1
|
||||||
|
109652, Г. МОСКВА, УЛ. ЛЮБЛИНСКАЯ, Д. 165, КОРП. 1, ЧАСТЬ КОМН. 2
|
||||||
|
111396, Г. МОСКВА, СВОБОДНЫЙ ПРОСПЕКТ, Д. 20А
|
||||||
|
111677, ГОРОД МОСКВА, УЛ. ПОКРОВСКАЯ, Д. 18, СТР. 1
|
||||||
|
113191, Г. МОСКВА, УЛ. БОЛЬШАЯ ТУЛЬСКАЯ, Д. 2
|
||||||
|
113452, Г. МОСКВА, БАЛАКЛАВСКИЙ ПРОСПЕКТ, ДОМ 26
|
||||||
|
115035, Г. МОСКВА, УЛ. БОЛЬШАЯ ОРДЫНКА, Д. 21, СТР. 2
|
||||||
|
115035, Г. МОСКВА, УЛ. БОЛЬШАЯ ОРДЫНКА, Д. 21, СТР. 2, ПОМ. 103
|
||||||
|
115054, Г. МОСКВА, УЛ. ВАЛОВАЯ, Д. 6
|
||||||
|
115054, Г. МОСКВА, УЛ. ЩИПОК, Д. 11, СТР. 1
|
||||||
|
115088, ГОРОД МОСКВА, УЛ. 2-Я МАШИНОСТРОЕНИЯ, Д.6
|
||||||
|
115142, Г. МОСКВА, УЛ. КОЛОМЕНСКАЯ, Д. 16
|
||||||
|
115184, ГОРОД МОСКВА, УЛ. МАЛАЯ ОРДЫНКА, ДОМ 44
|
||||||
|
115280, Г. МОСКВА, УЛ. АВТОЗАВОДСКАЯ, Д. 5
|
||||||
|
115280, РФ, Г. МОСКВА, УЛ. АВТОЗАВОДСКАЯ, Д. 5, ПОМ. 1, 2 (33,94 КВ.М.)
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1108
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1109
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1117
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1120
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 11, ПОМ. 1121
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 8, ПОМ. 818
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 8, ПОМ. 819
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 8, ПОМ. 837
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 9, ПОМ. 916
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 9, ПОМ. 917
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 6
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 8, ПОМ. 808
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 8, ПОМ. 810
|
||||||
|
115432, Г. МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 8, ПОМ. 843
|
||||||
|
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18 КОРП. 3, ЭТАЖ 6, ПОМ. 630
|
||||||
|
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18, КОРП. 3, ЭТАЖ 6, ПОМ. 607
|
||||||
|
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 6, ПОМ. 606
|
||||||
|
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 6, ПОМ. 611
|
||||||
|
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 6, ПОМ. 623
|
||||||
|
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 3, ЭТАЖ 6, ПОМ. 714
|
||||||
|
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 6, ЭТАЖ 9, ПОМ. 908
|
||||||
|
115432, ГОРОД МОСКВА, ПРОСП. АНДРОПОВА, Д. 18. КОРП. 6, ЭТАЖ 9, ПОМ. 915
|
||||||
|
115470, Г. МОСКВА, ПР-КТ АНДРОПОВА, Д. 21
|
||||||
|
115533, Г. МОСКВА, ПРОСПЕКТ АНДРОПОВА, ДОМ 22
|
||||||
|
115583, Г. МОСКВА, УЛ. ГЕНЕРАЛА БЕЛОВА, Д. 35
|
||||||
|
115583, РФ, Г. МОСКВА, УЛ. ГЕНЕРАЛА БЕЛОВА, Д. 35, ПОМ. 1 (12,64 КВ.М)
|
||||||
|
117042, Г. МОСКВА, УЛ. ВЕНЁВСКАЯ, Д. 6
|
||||||
|
117216, Г. МОСКВА, БУЛЬВАР ДОНСКОГО ДМИТРИЯ, Д. 11, ПОМ. № 14, 14А
|
||||||
|
117216, Г. МОСКВА, БУЛЬВАР ДОНСКОГО ДМИТРИЯ, Д. 11, ПОМ. № 9
|
||||||
|
117218, Г. МОСКВА, УЛ. ПРОФСОЮЗНАЯ, Д. 13/12
|
||||||
|
117218, Г. МОСКВА, УЛ. ПРОФСОЮЗНАЯ, Д. 13/12, ПОМ. 15
|
||||||
|
117485, Г. МОСКВА, УЛ. ПРОФСОЮЗНАЯ, ДОМ 104
|
||||||
|
117519, Г. МОСКВА, УЛ. КРАСНОГО МАЯКА, Д. 2Б
|
||||||
|
117534, Г. МОСКВА, ВАРШАВСКОЕ ШОССЕ Д. 150
|
||||||
|
117545, Г. МОСКВА, 1-ЫЙ ДОРОЖНЫЙ ПРОЕЗД, Д. 4, СТР. 1
|
||||||
|
117556, Г. МОСКВА, ВАРШАВСКОЕ ШОССЕ, ДОМ 76, КОРП. 1
|
||||||
|
117556, Г. МОСКВА, ВАРШАВСКОЕ ШОССЕ, ДОМ 76, КОРПУС 1
|
||||||
|
117556, ГОРОД МОСКВА, Ш. ВАРШАВСКОЕ, Д. 91А
|
||||||
|
119002, Г. МОСКВА, ПЕР. ДЕНЕЖНЫЙ, Д. 3, СТРОЕН. 1
|
||||||
|
119019, Г. МОСКВА, УЛ. АРБАТ, Д. 4, СТР. 1, ПОМ. 8А/1П
|
||||||
|
119034, Г. МОСКВА, КОМСОМОЛЬСКИЙ ПРОСПЕКТ, Д. 24, СТР. 1, ПОМЕЩЕНИЕ XIII, ЧАСТЬ КОМНАТЫ № 4
|
||||||
|
119034, Г. МОСКВА, КОМСОМОЛЬСКИЙ ПРОСПЕКТ, Д. 24, СТР. 1., ПОМЕЩЕНИЕ XII, КОМНАТЫ № 1,2,3,16,17,18
|
||||||
|
119121, Г. МОСКВА, ПЛОЩАДЬ СМОЛЕНСКАЯ-СЕННАЯ, Д.27, СТРОЕНИЕ 1
|
||||||
|
119121, РФ, Г. МОСКВА, ПЛ. СМОЛЕНСКАЯ-СЕННАЯ, Д. 27, СТР. 1, ЧАСТЬ ПОМ. 1 (12,65 КВ.М)
|
||||||
|
119180, Г. МОСКВА, УЛ. БОЛЬШАЯ ПОЛЯНКА, Д. 61, СТР. 2, ПОМ. 2/1
|
||||||
|
119192, Г. МОСКВА, ЛОМОНОСОВСКИЙ ПРОСПЕКТ, ДОМ 25, КОРП.1
|
||||||
|
119192, Г. МОСКВА, ЛОМОНОСОВСКИЙ ПРОСПЕКТ, ДОМ 25, КОРПУС 1
|
||||||
|
119421, Г. МОСКВА, ЛЕНИНСКИЙ ПРОСПЕКТ, Д. 99
|
||||||
|
119421, Г. МОСКВА, ЛЕНИНСКИЙ ПРОСПЕКТ, Д. 99, ПОМ. 22/4/1
|
||||||
|
119421, ГОРОД МОСКВА, ПР-КТ ЛЕНИНСКИЙ, Д. 107
|
||||||
|
121165, Г. МОСКВА, КУТУЗОВСКИЙ ПРОСПЕКТ, Д.30/32
|
||||||
|
121165, Г. МОСКВА, КУТУЗОВСКИЙ ПРОСПЕКТ, ДОМ 30/32, СТР. 1
|
||||||
|
121500, МОСКОВСКАЯ ОБЛАСТЬ, Г. ОДИНЦОВО, ТЕР. МКАД, КМ 63-Й, СТР. 1
|
||||||
|
121552, Г. МОСКВА, УЛ. ЯРЦЕВСКАЯ, Д. 27, КОРП. 1
|
||||||
|
121552, РФ, Г. МОСКВА, УЛ. ЯРЦЕВСКАЯ, Д. 27, КОРП. 1, ПОМ. 6 (13,92 КВ.М.)
|
||||||
|
121609, Г. МОСКВА, ОСЕННИЙ БУЛЬВАР, Д. 7, КОРП. 1, ПОМ. 1-001 - 1-004, 1-006 - 1-014
|
||||||
|
121609, Г. МОСКВА, ОСЕННИЙ БУЛЬВАР, Д. 7, КОРП. 1, ПОМ. 1-005
|
||||||
|
121609, Г. МОСКВА, РУБЛЁВСКОЕ ШОССЕ, Д. 64, К. 2
|
||||||
|
121609, Г. МОСКВА, РУБЛЕВСКОЕ ШОССЕ, ДОМ 74
|
||||||
|
121609, Г. МОСКВА, Ш. РУБЛЁВСКОЕ, Д. 74
|
||||||
|
123022, Г. МОСКВА, УЛ. ПРЕСНЕНСКИЙ ВАЛ, Д. 7, СТР. 1
|
||||||
|
123022, РФ, Г. МОСКВА, УЛ. ПРЕСНЕНСКИЙ ВАЛ, Д. 7, СТР. 1, ПОМ. 1 (21,71 КВ.М.)
|
||||||
|
123056, Г. МОСКВА, УЛ. БОЛЬШАЯ ГРУЗИНСКАЯ, Д. 57, СТР. 1
|
||||||
|
123060, ГОРОД МОСКВА, УЛИЦА НАРОДНОГО ОПОЛЧЕНИЯ, ДОМ 45
|
||||||
|
123104, Г. МОСКВА, УЛ. МАЛАЯ БРОННАЯ, Д. 15 Б, 1Н, 2Н, 3Н
|
||||||
|
123112, Г. МОСКВА, 1-Й КРАСНОГВАРДЕЙСКИЙ ПРОЕЗД, Д. 15
|
||||||
|
123112, Г. МОСКВА, НАБЕРЕЖНАЯ ПРЕСНЕНСКАЯ, Д. 2, ПОМЕЩ. 1Н/6
|
||||||
|
123154, Г. МОСКВА, ПРОСПЕКТ МАРШАЛА ЖУКОВА, ДОМ 49, КОРПУС 1
|
||||||
|
123290, Г. МОСКВА, 1-Й МАГИСТРАЛЬНЫЙ ПРОЕЗД, Д. 9
|
||||||
|
123290, Г. МОСКВА, УЛ. 2-Я МАГИСТРАЛЬНАЯ, Д. 18, СТР. 22
|
||||||
|
123557, Г. МОСКВА, БОЛЬШОЙ ТИШИНСКИЙ ПЕР., Д. 38
|
||||||
|
124683, Г. МОСКВА, ЗЕЛЕНОГРАД, КОРП. 1824
|
||||||
|
124683, Г. МОСКВА, ЗЕЛЕНОГРАД, КОРП.1824
|
||||||
|
125167, Г. МОСКВА, ПР-КТ ЛЕНИНГРАДСКИЙ, Д. 39А, ПОМ. 1
|
||||||
|
125171, Г. МОСКВА, ЛЕНИНГРАДСКОЕ ШОССЕ, Д. 15
|
||||||
|
125239, Г. МОСКВА, УЛ. КОПТЕВСКАЯ, Д. 69А, СТР. 1.
|
||||||
|
125239, Г. МОСКВА, УЛ. КОПТЕВСКАЯ, Д. 69А, СТР. 2
|
||||||
|
125239, Г. МОСКВА, УЛ. КОПТЕВСКАЯ, Д. 69А, СТР. 5
|
||||||
|
125252, Г. МОСКВА, УЛ. АВИАКОНСТРУКТОРА МИКОЯНА, Д. 12, ПОМ. 15/1
|
||||||
|
125252, Г. МОСКВА, УЛ. АВИАКОНСТРУКТОРА МИКОЯНА, Д. 12, ПОМ. 15/1, КАБ. 1
|
||||||
|
125252, ГОРОД МОСКВА, УЛ. ЗОРГЕ, Д. 17
|
||||||
|
125315, ГОРОД МОСКВА, ЛЕНИНГРАДСКИЙ ПР-КТ, Д. 78, КОРП. 1
|
||||||
|
125315, ГОРОД МОСКВА, ЛЕНИНГРАДСКИЙ ПР-КТ, Д. 78, КОРП. 1, ПОМ. 1-014
|
||||||
|
125363, Г. МОСКВА, Б-Р ЯНА РАЙНИСА, Д. 1
|
||||||
|
125363, Г. МОСКВА, Б-Р ЯНА РАЙНИСА, Д. 1, ПОМ. 1 (14.2 КВ. М)
|
||||||
|
125375, Г. МОСКВА, УЛ. ТВЕРСКАЯ, Д. 28, КОРП. 1, ПОМ. 1-015
|
||||||
|
125430, Г. МОСКВА, УЛ. МИТИНСКАЯ, Д. 36, КОРП. 1, ПОМ. 1, КОМН. 12, 13
|
||||||
|
125430, Г. МОСКВА, УЛ. МИТИНСКАЯ, Д. 36, КОРП. 1, ПОМ. 1, КОМН. 6-11, 14-19, 22-27
|
||||||
|
125445, Г. МОСКВА, ЛЕНИНГРАДСКОЕ ШОССЕ, Д. 71А, СТР.10
|
||||||
|
125466, Г. МОСКВА, УЛ. ВОРОТЫНСКАЯ, Д. 2
|
||||||
|
125466, РФ, Г. МОСКВА, УЛ. ВОРОТЫНСКАЯ, Д. 2, ПОМ. 1 (23,56 КВ.М)
|
||||||
|
125476, Г. МОСКВА, УЛ. ВАСИЛИЯ ПЕТУШКОВА, Д. 3, КОРП. 2
|
||||||
|
125565, Г. МОСКВА, УЛ. ФЕСТИВАЛЬНАЯ, Д. 13, КОРП. 1
|
||||||
|
125565, Г. МОСКВА, УЛ. ФЕСТИВАЛЬНАЯ, ДОМ 13, КОРП. 1
|
||||||
|
127015, Г. МОСКВА, УЛ. БУТЫРСКАЯ, Д. 89
|
||||||
|
127015, Г. МОСКВА, УЛ. БУТЫРСКАЯ, Д. 89, ПОМ. 9
|
||||||
|
127030, Г. МОСКВА, УЛ. НОВОСЛОБОДСКАЯ, Д. 23
|
||||||
|
127224, Г. МОСКВА, УЛ. ОСТАШКОВСКАЯ, Д. 22
|
||||||
|
127247, Г. МОСКВА, Ш. ДМИТРОВСКОЕ, Д. 107Д
|
||||||
|
127282, Г. МОСКВА, УЛ. ГРЕКОВА, Д. 3, КОРП. 3
|
||||||
|
127287, Г. МОСКВА, УЛ. БАШИЛОВСКАЯ, Д. 2, СТР. 3
|
||||||
|
127349, Г. МОСКВА, УЛ. ЛЕСКОВА, ДОМ 3Г
|
||||||
|
127434, Г. МОСКВА, Ш. ДМИТРОВСКОЕ, Д. 15, КОРП. 1
|
||||||
|
127434, Г. МОСКВА, Ш. ДМИТРОВСКОЕ, Д. 15, КОРП. 1, ПОМ. I, КОМНАТА №14
|
||||||
|
127566, Г. МОСКВА, УЛ. ДЕКАБРИСТОВ, Д. 17
|
||||||
|
127566, Г. МОСКВА, УЛ. РИМСКОГО-КОРСАКОВА, Д. 3, СТР. 1, ПОМ. 1/1
|
||||||
|
129085, Г. МОСКВА, ПР-КТ МИРА, Д. 91, КОРП. 2, ПОМ. 1-021
|
||||||
|
129085, Г. МОСКВА, ПРОСПЕКТ МИРА, Д. 91, КОРП. 2
|
||||||
|
129085, Г. МОСКВА, ПРОСПЕКТ МИРА, Д.91, КОРП.2
|
||||||
|
129090, Г. МОСКВА, ПРОСПЕКТ МИРА, Д. 42
|
||||||
|
129090, Г. МОСКВА, ПРОСПЕКТ МИРА, Д. 42, ПОМ. 10
|
||||||
|
129327, Г. МОСКВА, УЛ. МЕНЖИНСКОГО, Д. 23, КОРП. 1
|
||||||
|
129327, Г. МОСКВА, УЛ. МЕНЖИНСКОГО, Д. 23, КОРП. 1, ПОМ. 7
|
||||||
|
129337, Г. МОСКВА, ЯРОСЛАВСКОЕ Ш., Д. 11, КОРП. 1
|
||||||
|
129337, Г. МОСКВА, ЯРОСЛАВСКОЕ Ш., Д. 36
|
||||||
|
129337, ГОРОД МОСКВА, ЯРОСЛАВСКОЕ Ш., Д. 7
|
||||||
|
140000, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛЮБЕРЦЫ, ОКТЯБРЬСКИЙ ПРОСПЕКТ, Д. 151/9, П. 2
|
||||||
|
140000, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛЮБЕРЦЫ, ОКТЯБРЬСКИЙ ПРОСПЕКТ, Д. 151/9, ПОМ. 1
|
||||||
|
140053, МОСКОВСКАЯ ОБЛ., Г. КОТЕЛЬНИКИ, КОММЕРЧЕСКИЙ ПРОЕЗД, Д.10
|
||||||
|
140056, МОСКОВСКАЯ ОБЛАСТЬ, Г. КОТЕЛЬНИКИ, УЛ. СОСНОВАЯ, Д. 2, КОРП. 4
|
||||||
|
140080, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛЫТКАРИНО, УЛ. ПАРКОВАЯ, СТР. 2
|
||||||
|
140090, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДЗЕРЖИНСКИЙ, УЛ. ЛЕСНАЯ, Д. 4
|
||||||
|
140108, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ РАМЕНСКИЙ, ГОРОД РАМЕНСКОЕ, УЛИЦА МИХАЛЕВИЧА, ДОМ 1А
|
||||||
|
140184, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЖУКОВСКИЙ, УЛ. ГАГАРИНА, Д. 24
|
||||||
|
140200, МОСКОВСКАЯ ОБЛАСТЬ, Г. ВОСКРЕСЕНСК, УЛИЦА ОКТЯБРЬСКАЯ, ДОМ 2, ПОМ. 31
|
||||||
|
140301, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЕГОРЬЕВСК, УЛ. СОВЕТСКАЯ, Д. 82 «А»
|
||||||
|
140408, МОСКОВСКАЯ ОБЛАСТЬ, Г. КОЛОМНА, ПЛОЩАДЬ ВОССТАНИЯ, Д. 7, ПОМ. 131-132, ЧАСТЬ ПОМ. 134
|
||||||
|
141006, МОСКОВСКАЯ ОБЛАСТЬ, Г. МЫТИЩИ, ОЛИМПИЙСКИЙ ПРОСПЕКТ, ДОМ 19, КОРП. 1
|
||||||
|
141008, МОСКОВСКАЯ ОБЛАСТЬ, Г. МЫТИЩИ, УЛ. МИРА, СТРОЕНИЕ 26А
|
||||||
|
141011, МОСКОВСКАЯ ОБЛ., Г. МЫТИЩИ, УЛ. 3-Я КОЛХОЗНАЯ, Д. 9
|
||||||
|
141011, МОСКОВСКАЯ ОБЛАСТЬ, Г. МЫТИЩИ, УЛ. 3-Я КОЛХОЗНАЯ, Д.9 "А"
|
||||||
|
141031, МОСКОВСКАЯ ОБЛАСТЬ, Г. МЫТИЩИ, П. ВЕШКИ, ТЕР. ТПЗ АЛТУФЬЕВО, КИЛОМЕТР МКАД 85, СТР. 3А
|
||||||
|
141031, МОСКОВСКАЯ ОБЛАСТЬ, Г.О. МЫТИЩИ, Г. МЫТИЩИ, ТЕРРИТОРИЯ ТПЗ АЛТУФЬЕВО, ПРОЕЗД АВТОМОБИЛЬНЫЙ, ВЛАД. 5 СТР. 9
|
||||||
|
141080, МОСКОВСКАЯ ОБЛ., РОССИЯ, Г. КОРОЛЕВ, ПР-Т КОСМОНАВТОВ, Д. 27 Б
|
||||||
|
141151, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛОСИНО-ПЕТРОВСКИЙ, УЛ. КИРОВА, Д. 6А, ПОМ. 1-003
|
||||||
|
141195, МОСКОВСКАЯ ОБЛАСТЬ, Г. ФРЯЗИНО, ПР-КТ МИРА, Д. 8, ПОМ. 2003/2
|
||||||
|
141207, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПУШКИНО, МОСКОВСКИЙ ПРОСПЕКТ, Д. 9/2
|
||||||
|
141281, МОСКОВСКАЯ ОБЛАСТЬ, Г. ИВАНТЕЕВКА, УЛ. ХЛЕБОЗАВОДСКАЯ, Д. 30, ПОМ. 1-002
|
||||||
|
141310, МОСКОВСКАЯ ОБЛАСТЬ, Г. СЕРГИЕВ ПОСАД, ПРОСП. КРАСНОЙ АРМИИ, Д. 180, ПОМ. 128
|
||||||
|
141400, РФ, МОСКОВСКАЯ ОБЛ., Г. ХИМКИ, ПР-Т МЕЛЬНИКОВА, Д. 2Б, ПОМ. 1 (14,35 КВ.М.)
|
||||||
|
141407, МОСКОВСКАЯ ОБЛАСТЬ, Г. ХИМКИ, 78 КМ. МКАД, ВЛ.2
|
||||||
|
141407, МОСКОВСКАЯ ОБЛАСТЬ, Г. ХИМКИ, КМ. МКАД 74-Й, ВЛД. 5
|
||||||
|
141408, МОСКОВСКАЯ ОБЛ., Г. ХИМКИ, ЛЕНИНГРАДСКОЕ Ш., ВЛ. 21
|
||||||
|
141408, МОСКОВСКАЯ ОБЛАСТЬ, Г. ХИМКИ, ЛЕНИНГРАДСКОЕ Ш., ВЛ. 21, ПОМ. 1
|
||||||
|
141410, МОСКОВСКАЯ ОБЛАСТЬ, Г. ХИМКИ, ЛЕНИНГРАДСКОЕ Ш., ВЛД. 14 СТР. 1
|
||||||
|
141506, МОСКОВСКАЯ ОБЛАСТЬ, Г. СОЛНЕЧНОГОРСК, УЛ. ИМЕНИ БАРАНОВА, Д. 12
|
||||||
|
141607, МОСКОВСКАЯ ОБЛАСТЬ, Г. КЛИН, УЛ. КАРЛА МАРКСА, Д. 4
|
||||||
|
141700, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДОЛГОПРУДНЫЙ, НОВЫЙ БУЛЬВАР, Д. 2
|
||||||
|
141730, МОСКОВСКАЯ ОБЛАСТЬ, Г. ЛОБНЯ, УЛ. ЛЕНИНА, Д. 16
|
||||||
|
141800, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДМИТРОВ, УЛ. ЗАГОРСКАЯ, Д. 24, ПОМ. ЛИТБ58
|
||||||
|
141986, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДУБНА, ПРОСПЕКТ БОГОЛЮБОВА, Д. 14А
|
||||||
|
142000, МОСКОВСКАЯ ОБЛАСТЬ, Г. ДОМОДЕДОВО, МИКРОРАЙОН ЦЕНТРАЛЬНЫЙ, КАШИРСКОЕ ШОССЕ, Д. 51
|
||||||
|
142034, МОСКОВСКАЯ ОБЛАСТЬ, Г. ВИДНОЕ, ПОС. МЕЩЕРИНО, ЗОНА ПРИДОРОЖНОГО СЕРВИСА АВТОДОРОГИ МКАД-АЭРОПОРТ ДОМОДЕДОВО, ВЛД. 5
|
||||||
|
142100, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПОДОЛЬСК, УЛ. РАБОЧАЯ, Д. 4
|
||||||
|
142100, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПОДОЛЬСК, УЛ. РАБОЧАЯ, Д. 4, ЧАСТЬ ПОМ. 1
|
||||||
|
142103, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ ПОДОЛЬСК, ТЕРРИТОРИЯ АВТОДОРОГА М-2 КРЫМ, КИЛОМЕТР 30-Й, Д. 1, СТР. 1
|
||||||
|
142118, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПОДОЛЬСК, МКР. КУЗНЕЧИКИ, УЛ. АКАДЕМИКА ДОЛЛЕЖАЛЯ, Д. 3, СТР. 2
|
||||||
|
142203, МОСКОВСКАЯ ОБЛАСТЬ, Г. СЕРПУХОВ, УЛ. ВОРОШИЛОВА, Д. 127
|
||||||
|
142281, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПРОТВИНО, УЛ. ЛЕНИНА, Д. 22
|
||||||
|
142400, МОСКОВСКАЯ ОБЛАСТЬ, Г. НОГИНСК, УЛ. 3-ГО ИНТЕРНАЦИОНАЛА, Д. 62
|
||||||
|
142500, МОСКОВСКАЯ ОБЛАСТЬ, Г. ПАВЛОВСКИЙ ПОСАД, УЛ. БОЛЬШАЯ ПОКРОВСКАЯ, Д. 10
|
||||||
|
142703, МОСКОВСКАЯ ОБЛАСТЬ, Г. ВИДНОЕ, УЛ. ШКОЛЬНАЯ, 23
|
||||||
|
142715, МОСКОВСКАЯ ОБЛАСТЬ, ЛЕНИНСКИЙ Г.О., Д. БЛИЖНИЕ ПРУДИЩИ, 27 КМ МКАД, ВЛ. 9
|
||||||
|
142718, МОСКОВСКАЯ ОБЛ., ЛЕНИНСКИЙ Р-Н, СЕЛЬСКОЕ ПОСЕЛЕНИЕ БУЛАТНИКОВСКОЕ, РАЙОН 29 КМ МКАД, УЧ. 1 (ЧАСТЬ ПОМ. 104)
|
||||||
|
142718, МОСКОВСКАЯ ОБЛАСТЬ, ЛЕНИНСКИЙ ГОРОДСКОЙ ОКРУГ, ПОС. БИТЦА ТЕР. МКАД, УЛ. 32-Й КМ., СТР. 3
|
||||||
|
142784, Г. МОСКВА, П. МОСКОВСКИЙ, КИЕВСКОЕ ШОССЕ, 22-Й КМ., ДОМОВЛ. 4, СТР. 1
|
||||||
|
142784, Г.МОСКВА, П. МОСКОВСКИЙ, КИЕВСКОЕ ШОССЕ, 22-Й КМ., ДОМОВЛ. 4, СТР. 1
|
||||||
|
142802, МОСКОВСКАЯ ОБЛАСТЬ, Г. СТУПИНО, УЛ. АНДРОПОВА, Д. 63
|
||||||
|
142901, МОСКОВСКАЯ ОБЛАСТЬ, Г. КАШИРА, УЛ. ИЛЬИЧА, Д. 61
|
||||||
|
143005, МОСКОВСКАЯ ОБЛ., Г. ОДИНЦОВО, МОЖАЙСКОЕ Ш., Д. 76 А, 2 ЭТАЖ, ПОМ. 1 (23,9 КВ.М.)
|
||||||
|
143005, МОСКОВСКАЯ ОБЛАСТЬ, Г. ОДИНЦОВО, МОЖАЙСКОЕ ШОССЕ, ДОМ 76А
|
||||||
|
143025, МОСКОВСКАЯ ОБЛАСТЬ, Г.О. ОДИНЦОВСКИЙ, С. НЕМЧИНОВКА, УЛ. МОСКОВСКАЯ, Д. 61
|
||||||
|
143082, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ ОДИНЦОВСКИЙ, Д. РАЗДОРЫ, ТЕР. МКАД КМ. 61, СТР. 1
|
||||||
|
143085, МОСКОВСКАЯ ОБЛАСТЬ, ОДИНЦОВСКИЙ РАЙОН, РАБОЧИЙ ПОСЕЛОК ЗАРЕЧЬЕ, УЛ. ТОРГОВАЯ, Д.4, СТР. 1
|
||||||
|
143090, МОСКОВСКАЯ ОБЛАСТЬ, Г. КРАСНОЗНАМЕНСК, УЛ. МОЛОДЕЖНАЯ, ЗД. 2/1
|
||||||
|
143132, МОСКОВСКАЯ ОБЛАСТЬ, Г. РУЗА, П.Г.Т. ТУЧКОВО, УЛ. ПРИВОКЗАЛЬНАЯ ПЛОЩАДЬ, Д. 9, ПОМ. 22, 25, 26
|
||||||
|
143200, МОСКОВСКАЯ ОБЛАСТЬ, Г.О. МОЖАЙСКИЙ, Г. МОЖАЙСК, УЛ. МИРА, Д. 11/1
|
||||||
|
143300, МОСКОВСКАЯ ОБЛАСТЬ, Г. НАРО-ФОМИНСК, ПЛОЩАДЬ СВОБОДЫ, Д. 2
|
||||||
|
143360, МОСКОВСКАЯ ОБЛАСТЬ, Г.О. НАРО-ФОМИНСКИЙ, Г. АПРЕЛЕВКА, УЛ. АВГУСТОВСКАЯ, Д. 14
|
||||||
|
143404, МОСКОВСКАЯ ОБЛАСТЬ, Г. КРАСНОГОРСК, УЛ. ЛЕНИНА, ДОМ 38Б
|
||||||
|
143432, МОСКОВСКАЯ ОБЛАСТЬ, Г. КРАСНОГОРСК, Р.П. НАХАБИНО, УЛ. ИНСТИТУТСКАЯ, Д. 17
|
||||||
|
143500, МОСКОВСКАЯ ОБЛАСТЬ, Г. ИСТРА, УЛ. ЛЕНИНА, Д. 75, КОМ. № 105, 106
|
||||||
|
143600, МОСКОВСКАЯ ОБЛАСТЬ, Г. ВОЛОКОЛАМСК, УЛ. СОВЕТСКАЯ, Д. 1/1
|
||||||
|
143912, МОСКОВСКАЯ ОБЛАСТЬ, Г. БАЛАШИХА, Ш. ЭНТУЗИАСТОВ, Д. 12Б
|
||||||
|
143912, МОСКОВСКАЯ ОБЛАСТЬ, Г. БАЛАШИХА, ШОССЕ ЭНТУЗИАСТОВ, ДОМ 7/1, ПОМ. 1
|
||||||
|
143914, МОСКОВСКАЯ ОБЛАСТЬ, Г. БАЛАШИХА, МКРН. ЦОВБ, Д. 21, ПОМ. 1
|
||||||
|
143960, МОСКОВСКАЯ ОБЛАСТЬ, Г. РЕУТОВ, 2-3 КМ МКАД, Д. 7
|
||||||
|
143960, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ РЕУТОВ, Г. РЕУТОВ, 2-3 КМ. МКАД, ДОМ 7
|
||||||
|
143964, МОСКОВСКАЯ ОБЛАСТЬ, Г. РЕУТОВ, УЛ. МКАД 3 КМ, Д.11
|
||||||
|
143965, МОСКОВСКАЯ ОБЛАСТЬ, Г. РЕУТОВ, НОСОВИХИНСКОЕ ШОССЕ, ДОМ 7, ПОМЕЩЕНИЕ V
|
||||||
|
143980, МОСКОВСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ БАЛАШИХА, Г. БАЛАШИХА, МИКРОРАЙОН ЖЕЛЕЗНОДОРОЖНЫЙ, УЛ. ПРОЛЕТАРСКАЯ, Д. 2А, ПОМЕЩЕНИЕ 1
|
||||||
|
152020, ЯРОСЛАВСКАЯ ОБЛАСТЬ, Г. ПЕРЕСЛАВЛЬ-ЗАЛЕССКИЙ, УЛ. РОСТОВСКАЯ, Д. 4
|
||||||
|
152155, ЯРОСЛАВСКАЯ ОБЛАСТЬ, РОСТОВСКИЙ Р-Н, Г. РОСТОВ ВЕЛИКИЙ, УЛ. КАРЛА МАРКСА, Д. 10
|
||||||
|
152300, ЯРОСЛАВСКАЯ ОБЛАСТЬ, Г. ТУТАЕВ, ПР-КТ 50-ЛЕТИЯ ПОБЕДЫ, ЗД. 9А
|
||||||
|
152615, ЯРОСЛАВСКАЯ ОБЛАСТЬ, УГЛИЧСКИЙ РАЙОН, Г. УГЛИЧ, УЛ. ЯРОСЛАВСКАЯ, Д. 8
|
||||||
|
152903, ЯРОСЛАВСКАЯ ОБЛАСТЬ, РЫБИНСКИЙ РАЙОН, Г. РЫБИНСК, УЛ. КИРОВА, Д. 18
|
||||||
|
153000, Г. ИВАНОВО, ПРОСПЕКТ ЛЕНИНА, 11
|
||||||
|
153000, Г. ИВАНОВО, ПРОСПЕКТ ЛЕНИНА, 11, П. 1
|
||||||
|
153038, ИВАНОВСКАЯ ОБЛАСТЬ, Г. ИВАНОВО, ПР-КТ СТРОИТЕЛЕЙ, Д. 25
|
||||||
|
153511, ИВАНОВСКАЯ ОБЛАСТЬ, Г. КОХМА, УЛ. ИВАНОВСКАЯ, Д. 29, ПОМ. 2, 3
|
||||||
|
155040, ИВАНОВСКАЯ ОБЛАСТЬ, ТЕЙКОВСКИЙ РАЙОН, ГОРОД ТЕЙКОВО, УЛИЦА ОКТЯБРЬСКАЯ, Д. 12, ПОМ. 1005
|
||||||
|
155800, ИВАНОВСКАЯ ОБЛАСТЬ, Г. КИНЕШМА, УЛ. ИМ ЛЕНИНА, Д. 6, КВ. 1
|
||||||
|
156000, КОСТРОМСКАЯ ОБЛАСТЬ, Г. КОСТРОМА, УЛ. СОВЕТСКАЯ, Д. 30
|
||||||
|
156000, КОСТРОМСКАЯ ОБЛАСТЬ, Г. КОСТРОМА, УЛ. СОВЕТСКАЯ, Д. 30, ПОМ. 1-004
|
||||||
|
156013, КОСТРОМСКАЯ ОБЛАСТЬ, Г. КОСТРОМА, ПР-Т МИРА, Д. 65/51, ПОМ. 2
|
||||||
|
156901, КОСТРОМСКАЯ ОБЛАСТЬ, Г. ВОЛГОРЕЧЕНСК, УЛ. ИМЕНИ 50-ЛЕТИЯ ЛЕНИНСКОГО КОМСОМОЛА, Д. 35
|
||||||
|
157006, КОСТРОМСКАЯ ОБЛАСТЬ, Г. БУЙ, УЛ. ОКТЯБРЬСКОЙ РЕВОЛЮЦИИ, ЗД. 49А
|
||||||
|
157800, КОСТРОМСКАЯ ОБЛАСТЬ, НЕРЕХТСКИЙ Р-Н, Г. НЕРЕХТА, УЛ. МЕТАЛЛИСТОВ, Д. 4
|
||||||
|
160000, ВОЛОГОДСКАЯ ОБЛАСТЬ, Г. ВОЛОГДА, УЛ. ЛЕНИНА, Д. 5
|
||||||
|
162130, ВОЛОГОДСКАЯ ОБЛАСТЬ, Г. СОКОЛ, УЛ. СОВЕТСКАЯ, Д. 87
|
||||||
|
162390, ВОЛОГОДСКАЯ ОБЛАСТЬ, ВЕЛИКОУСТЮГСКИЙ Р-Н, Г. ВЕЛИКИЙ УСТЮГ, ПР-КТ СОВЕТСКИЙ, Д. 99
|
||||||
|
163000, Г. АРХАНГЕЛЬСК, УЛ. ВОСКРЕСЕНСКАЯ, Д. 6
|
||||||
|
163060, Г. АРХАНГЕЛЬСК, УЛ. ВОСКРЕСЕНСКАЯ, Д.108
|
||||||
|
164171, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. МИРНЫЙ, УЛ. ЛЕНИНА, Д. 26, ПОМ. 35
|
||||||
|
164504, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. СЕВЕРОДВИНСК, УЛ. ЛОМОНОСОВА, Д. 64, ПОМ. 20020, КОМН. №17; №18; №20; №21; №23, ЧАСТЬ КОМНАТЫ №25; №26
|
||||||
|
165150, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. ВЕЛЬСК, УЛ. ДЗЕРЖИНСКОГО, Д. 86, СТР. 3
|
||||||
|
165313, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. КОТЛАС, УЛ. НЕВСКОГО, Д. 20
|
||||||
|
165653, АРХАНГЕЛЬСКАЯ ОБЛАСТЬ, Г. КОРЯЖМА, ПРОСПЕКТ ЛЕНИНА, Д. 11, ПОМ. 4
|
||||||
|
166000, НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. НАРЬЯН-МАР, УЛИЦА ИМ. В.И. ЛЕНИНА, Д. 19, ПОМ. 1-Н
|
||||||
|
167001, РЕСПУБЛИКА КОМИ, Г. СЫКТЫВКАР, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 46
|
||||||
|
169313, РЕСПУБЛИКА КОМИ, Г. УХТА, ПРОСПЕКТ ЛЕНИНА, Д. 27
|
||||||
|
169600, РЕСПУБЛИКА КОМИ, Г. ПЕЧОРА, УЛ. ГАГАРИНА, Д. 41
|
||||||
|
169710, РЕСПУБЛИКА КОМИ, Г. УСИНСК, УЛ. НЕФТЯНИКОВ, Д. 50
|
||||||
|
170100, Г.ТВЕРЬ, УЛ. ЖЕЛЯБОВА, Д. 3
|
||||||
|
170100, РФ, ТВЕРСКАЯ ОБЛ., Г. ТВЕРЬ, УЛ. ЖЕЛЯБОВА, Д. 3, ПОМ. 1-11 (132,2 КВ.М.)
|
||||||
|
170100, ТВЕРСКАЯ ОБЛАСТЬ, Г. ТВЕРЬ, УЛ. ЖЕЛЯБОВА, Д. 3, ПОМ.12
|
||||||
|
170100, ТВЕРСКАЯ ОБЛАСТЬ, Г. ТВЕРЬ, УЛ. ТРЕХСВЯТСКАЯ, Д. 28
|
||||||
|
170100, ТВЕРСКАЯ ОБЛАСТЬ, Г. ТВЕРЬ, УЛ. ТРЕХСВЯТСКАЯ, Д. 28, КАБ. 1-011
|
||||||
|
171163, ТВЕРСКАЯ ОБЛАСТЬ, Г. ВЫШНИЙ ВОЛОЧЕК, ПРОСПЕКТ КАЗАНСКИЙ, Д. 44, КВ. 1005
|
||||||
|
171252, ТВЕРСКАЯ ОБЛ., Г. КОНАКОВО, ПР-Т ЛЕНИНА, Д. 12
|
||||||
|
171506, ТВЕРСКАЯ ОБЛАСТЬ, Г. КИМРЫ, УЛ. УРИЦКОГО, Д. 4/6
|
||||||
|
171841, ТВЕРСКАЯ ОБЛАСТЬ, Г. УДОМЛЯ, УЛ. ПОПОВА, Д. 23
|
||||||
|
172008, ТВЕРСКАЯ ОБЛАСТЬ, Г. ТОРЖОК, УЛ. СТЕПАНА РАЗИНА, Д. 2-4
|
||||||
|
172390, ТВЕРСКАЯ ОБЛАСТЬ, Г. РЖЕВ, УЛИЦА ЛЕНИНА, Д. 7, ПОМ. 1
|
||||||
|
172527, ТВЕРСКАЯ ОБЛАСТЬ, Г. НЕЛИДОВО, УЛ. МАТРОСОВА, Д. 29
|
||||||
|
173001, НОВГОРОДСКАЯ ОБЛАСТЬ, Г. ВЕЛИКИЙ НОВГОРОД, УЛ. БОЛЬШАЯ САНКТ-ПЕТЕРБУРГСКАЯ, Д. 13, ПОМ. 1-15
|
||||||
|
174411, НОВГОРОДСКАЯ ОБЛАСТЬ, БОРОВИЧСКИЙ РАЙОН, Г. БОРОВИЧИ, УЛ. ПОДБЕЛЬСКОГО, Д. 36
|
||||||
|
175204, НОВГОРОДСКАЯ ОБЛАСТЬ, Г. СТАРАЯ РУССА, УЛ. ГОСТИНОДВОРСКАЯ, Д. 4/18, ПОМЕЩ. 16
|
||||||
|
180004, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПСКОВСКАЯ ОБЛАСТЬ, Г. ПСКОВ, ЯНА ФАБРИЦИУСА УЛ., ДОМ 27, ПОМ. 1005 (ЧАСТЬ КОМНАТЫ №15, ЧАСТЬ КОМНАТЫ №17, КОМНАТА №18, КОМНАТА №19, КОМНАТА №20, ЧАСТЬ КОМНАТЫ №21)
|
||||||
|
180007, ПСКОВСКАЯ ОБЛ., Г. ПСКОВ, ПР-Т РИЖСКИЙ, Д. 16
|
||||||
|
182113, ПСКОВСКАЯ ОБЛАСТЬ, Г. ВЕЛИКИЕ ЛУКИ, ПРОСПЕКТ ЛЕНИНА, Д. 39
|
||||||
|
183038, Г. МУРМАНСК, ПР. ЛЕНИНА, Д. 19А
|
||||||
|
183038, МУРМАНСКАЯ ОБЛАСТЬ, Г. МУРМАНСК, ПР-КТ ЛЕНИНА, Д. 82, ЛИТЕРА А4, ПОМ. X, КОМНАТА №30
|
||||||
|
183052, МУРМАНСКАЯ ОБЛАСТЬ, Г. МУРМАНСК, ПР-Т КОЛЬСКИЙ, Д. 158, II ЛИТЕР Б1, ПОМ. 35, 36, 37, 2 (ЧАСТЬ)
|
||||||
|
184042, МУРМАНСКАЯ ОБЛАСТЬ, Г. КАНДАЛАКША, УЛИЦА ПЕРВОМАЙСКАЯ, Д. 44
|
||||||
|
184209, МУРМАНСКАЯ ОБЛАСТЬ, Г. АПАТИТЫ, УЛ. ЛЕНИНА, Д. 4
|
||||||
|
184511, МУРМАНСКАЯ ОБЛАСТЬ, Г. МОНЧЕГОРСК, ПРОСПЕКТ МЕТАЛЛУРГОВ, Д. 34
|
||||||
|
184536, МУРМАНСКАЯ ОБЛАСТЬ, Г. ОЛЕНЕГОРСК, ПРОСПЕКТ ЛЕНИНГРАДСКИЙ, Д. 5
|
||||||
|
184604, МУРМАНСКАЯ ОБЛАСТЬ, Г. СЕВЕРОМОРСК, УЛ. САФОНОВА, Д. 13, ПОМ. 1-5
|
||||||
|
185014, РЕСПУБЛИКА КАРЕЛИЯ, Г. ПЕТРОЗАВОДСК, Ш. ЛОСОСИНСКОЕ, Д. 26
|
||||||
|
185035, РФ, РЕСПУБЛИКА КАРЕЛИЯ, Г. ПЕТРОЗАВОДСК, ПР-Т ЛЕНИНА, Д. 26
|
||||||
|
186225, РЕСПУБЛИКА КАРЕЛИЯ, Г. КОНДОПОГА, УЛ. ПРОЛЕТАРСКАЯ, Д. 6, ПОМ. 1
|
||||||
|
186424, РЕСПУБЛИКА КАРЕЛИЯ, Г. СЕГЕЖА, БУЛЬВАР СОВЕТОВ, Д. 3
|
||||||
|
186930, РЕСПУБЛИКА КАРЕЛИЯ, Г. КОСТОМУКША, БУЛЬВАР ЛАЗАРЕВА, Д. 1
|
||||||
|
187000, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ТОСНО, УЛ. ВОКЗАЛЬНАЯ, Д. 16
|
||||||
|
187112, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. КИРИШИ, ПР-КТ ГЕРОЕВ, Д. 2, КВ. 164
|
||||||
|
187342, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. КИРОВСК, УЛ. НАБЕРЕЖНАЯ, Д. 6
|
||||||
|
187406, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛХОВ, УЛ. МОЛОДЕЖНАЯ, Д. 18
|
||||||
|
187553, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ТИХВИН, УЛ. КАРЛА МАРКСА, Д. 50
|
||||||
|
188230, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ЛУГА, ПРОСПЕКТ УРИЦКОГО, Д. 77, КОРП. 3
|
||||||
|
188300, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ГАТЧИНА, ПРОСПЕКТ 25 ОКТЯБРЯ, Д. 11/13
|
||||||
|
188480, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. КИНГИСЕПП, УЛ. БОЛЬШАЯ СОВЕТСКАЯ, Д. 39Б
|
||||||
|
188541, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. СОСНОВЫЙ БОР, УЛ. СОЛНЕЧНАЯ, Д.12
|
||||||
|
188560, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. СЛАНЦЫ, УЛ. ЛЕНИНА, Д. 10, ПОМ. № 1
|
||||||
|
188640, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ВСЕВОЛОЖСК, ВСЕВОЛОЖСКИЙ ПР-КТ, Д. 29
|
||||||
|
188650, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. СЕРТОЛОВО, МИКРОРАЙОН СЕРТОЛОВО-1, УЛ. МОЛОДЦОВА, Д. 1, КОРП. 3
|
||||||
|
188677, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. МУРИНО, БУЛЬВАР МЕНДЕЛЕЕВА, Д. 11, КОРП. 1
|
||||||
|
188693, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. КУДРОВО, ЕВРОПЕЙСКИЙ ПРОСПЕКТ, Д. 8, ПОМ. 44-Н
|
||||||
|
188800, ЛЕНИНГРАДСКАЯ ОБЛАСТЬ, Г. ВЫБОРГ, УЛ. ВОКЗАЛЬНАЯ, Д. 7
|
||||||
|
191014, Г. САНКТ-ПЕТЕРБУРГ, БАСКОВ ПЕРЕУЛОК, Д. 2, СТР. 1, ПОМЕЩЕНИЕ 67-Н
|
||||||
|
191040 Г. САНКТ-ПЕТЕРБУРГ ЛИГОВСКИЙ ПР., 73, ЛИТЕР А
|
||||||
|
191123, Г. САНКТ-ПЕТЕРБУРГ, ФУРШТАТСКАЯ УЛИЦА, Д. 40, ЛИТ. А
|
||||||
|
191123, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. САНКТ-ПЕТЕРБУРГ, ФУРШТАТСКАЯ УЛИЦА, Д.40, ЛИТ.А (КАБИНЕТЫ №№ 1-9)
|
||||||
|
191186, Г. САНКТ-ПЕТЕРБУРГ, КАЗАНСКАЯ УЛ., 2, ЛИТ. А, ПОМ. 3Н
|
||||||
|
191186, Г. САНКТ-ПЕТЕРБУРГ, УЛ. БОЛЬШАЯ КОНЮШЕННАЯ, 19/8, ЛИТ. А, ПОМ. 24 Н-25 Н
|
||||||
|
192131, Г. САНКТ-ПЕТЕРБУРГ, УЛ. ИВАНОВСКАЯ, Д. 6, ЛИТЕРА А, ПОМ. 19-Н
|
||||||
|
192177, Г. САНКТ-ПЕТЕРБУРГ, УЛ. КАРАВАЕВСКАЯ, Д. 24, КОРП. 1, ЛИТ. А, ПОМ. 21-Н
|
||||||
|
192238, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. САНКТ-ПЕТЕРБУРГ, БУХАРЕСТСКАЯ УЛИЦА, Д. 74, КОР.3, ЛИТ.А
|
||||||
|
193168, Г. САНКТ-ПЕТЕРБУРГ, ПРОСПЕКТ БОЛЬШЕВИКОВ, Д. 17, ЛИТЕРА Ч, ПОМ. 47-Н
|
||||||
|
193231,РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. САНКТ-ПЕТЕРБУРГ, ПРОСПЕКТ БОЛЬШЕВИКОВ, ДОМ 2, ЛИТЕР А
|
||||||
|
194021, Г. САНКТ-ПЕТЕРБУРГ, ПР. ТОРЕЗА, Д. 9, ЛИТ. А
|
||||||
|
194356, Г. САНКТ-ПЕТЕРБУРГ, ПР. ЭНГЕЛЬСА, Д. 139/21, ЛИТ. А
|
||||||
|
195009, Г. САНКТ-ПЕТЕРБУРГ, УЛ. АКАДЕМИКА ЛЕБЕДЕВА, Д.31, КОРП.2, ЛИТ.А
|
||||||
|
195112, Г. САНКТ-ПЕТЕРБУРГ, НОВОЧЕРКАССКИЙ ПР., 43/17, ЛИТЕР А
|
||||||
|
195220, Г. САНКТ-ПЕТЕРБУРГ, ПРОСПЕКТ НАУКИ, Д. 14, КОР. 1, ЛИТ. А, ПОМ. 35Н
|
||||||
|
195267, Г. САНКТ-ПЕТЕРБУРГ, ПР.ПРОСВЕЩЕНИЯ, 84, КОРП.1, ЛИТ.А, ПОМ.50Н
|
||||||
|
196066, Г. САНКТ-ПЕТЕРБУРГ, МОСКОВСКИЙ ПР., Д.193, ПОМ.14Н, ЛИТ.А
|
||||||
|
196105, Г. САНКТ-ПЕТЕРБУРГ, МОСКОВСКИЙ ПРОСПЕКТ, Д. 172, ЛИТ. А, ПОМ. 11-Н
|
||||||
|
196233, Г. САНКТ-ПЕТЕРБУРГ, ПРОСПЕКТ ВИТЕБСКИЙ, Д. 101, КОРП. 1, СТР. 1, ПОМ. 538Н
|
||||||
|
196608, Г. САНКТ-ПЕТЕРБУРГ, Г. ПУШКИН, УЛ. ЛЕНИНГРАДСКАЯ, Д. 29, ЛИТ. А, ПОМ. 1-Н
|
||||||
|
196655, Г. САНКТ-ПЕТЕРБУРГ, Г. КОЛПИНО, УЛ. ТВЕРСКАЯ, Д. 34, ЛИТ. А
|
||||||
|
197022, Г. САНКТ-ПЕТЕРБУРГ, КАМЕННООСТРОВСКИЙ ПРОСПЕКТ, Д. 41, ЛИТЕРА А, ПОМ. 2-Н
|
||||||
|
197022, Г. САНКТ-ПЕТЕРБУРГ, МАЛЫЙ ПРОСПЕКТ П.С., Д. 87, ЛИТ. А, ЧАСТЬ ПОМ. 1-Н
|
||||||
|
197348, Г. САНКТ-ПЕТЕРБУРГ, КОЛОМЯЖСКИЙ ПРОСПЕКТ, ДОМ 15, КОРПУС 2, ЛИТЕРА А
|
||||||
|
197371, Г. САНКТ-ПЕТЕРБУРГ, КОМЕНДАНТСКИЙ ПРОСПЕКТ, Д. 13, КОРП. 1, ЛИТЕРА А, ПОМЕЩЕНИЕ 1-Н
|
||||||
|
197374, Г. САНКТ-ПЕТЕРБУРГ, УЛ. САВУШКИНА, Д. 124, КОРП. 1, ЛИТЕРА А, ПОМ. 10-Н
|
||||||
|
198097, Г. САНКТ-ПЕТЕРБУРГ, ПР.СТАЧЕК, 47, ЛИТЕР Е
|
||||||
|
198216, Г. САНКТ-ПЕТЕРБУРГ, ПР-Т ЛЕНИНСКИЙ, Д. 130/6, ЛИТ. А
|
||||||
|
198217, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. САНКТ-ПЕТЕРБУРГ, ДАЧНЫЙ ПРОСПЕКТ, Д.17, КОРП.3, ЛИТ.А, ПОМ.3Н
|
||||||
|
198510, Г. САНКТ-ПЕТЕРБУРГ, Г. ПЕТЕРГОФ, ПР-КТ САНКТ-ПЕТЕРБУРГСКИЙ, Д. 29, ЛИТЕРА А, ЧАСТЬ ПОМ. 1-Н
|
||||||
|
199004, Г. САНКТ ПЕТЕРБУРГ, ПР-КТ СРЕДНИЙ В.О., ДОМ 33, ЛИТЕР А, ПОМ. 10-Н
|
||||||
|
199004, Г. САНКТ-ПЕТЕРБУРГ, ВАСИЛЬЕВСКИЙ ОСТРОВ, 7-АЯ ЛИНИЯ, Д.54, ЛИТ.А, ПОМ.3Н
|
||||||
|
214004, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. СМОЛЕНСК, УЛ. НИКОЛАЕВА, Д. 8
|
||||||
|
214004, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. СМОЛЕНСК, УЛ. НИКОЛАЕВА, Д. 8, ПОМ. 3
|
||||||
|
214036, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. СМОЛЕНСК, УЛ. ПЕТРА АЛЕКСЕЕВА, Д. 1А
|
||||||
|
215110, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. ВЯЗЬМА, УЛ. 25 ОКТЯБРЯ, Д. 4
|
||||||
|
215500, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. САФОНОВО, УЛ. СОВЕТСКАЯ, Д. 56
|
||||||
|
216400, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. ДЕСНОГОРСК, МИКРОРАЙОН 4, Д. 7
|
||||||
|
216500, СМОЛЕНСКАЯ ОБЛАСТЬ, Г. РОСЛАВЛЬ, УЛ. ПРОЛЕТАРСКАЯ, Д. 46
|
||||||
|
236001, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. КАЛИНИНГРАД, УЛ. ГЕНЕРАЛА ЧЕЛНОКОВА, Д. 11
|
||||||
|
236006, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. КАЛИНИНГРАД, УЛ. ТЕАТРАЛЬНАЯ, Д. 35, ПОМ. 3
|
||||||
|
236006, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. КАЛИНИНГРАД, УЛ. ШЕВЧЕНКО, Д. 11А
|
||||||
|
236022, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КАЛИНИНГРАДСКАЯ ОБЛ., Г. КАЛИНИНГРАД, ПЛ. ПОБЕДЫ, Д. 4, ЛИТ. "V"
|
||||||
|
236022, РФ, КАЛИНИНГРАДСКАЯ ОБЛ., Г. КАЛИНИНГРАД, ПЛ. ПОБЕДЫ, Д. 4, ЛИТЕР "А"
|
||||||
|
236041, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. КАЛИНИНГРАД, УЛ. КУЙБЫШЕВА, Д. 2
|
||||||
|
236044, КАЛИНИНГРАДСКАЯ ОБЛ., Г. КАЛИНИНГРАД, Б-Р Л. ШЕВЦОВОЙ, Д. 1А
|
||||||
|
238300, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. ГУРЬЕВСК, БУЛЬВАР ПРАЖСКИЙ, ЗД. 2А
|
||||||
|
238326, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. ЗЕЛЕНОГРАДСК, УЛ. ПОТЕМКИНА, Д. 15Б
|
||||||
|
238340, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. СВЕТЛЫЙ, УЛ. СОВЕТСКАЯ, Д. 13, ПОМ. V ИЗ ЛИТ. А.
|
||||||
|
238750, КАЛИНИНГРАДСКАЯ ОБЛАСТЬ, Г. СОВЕТСК, УЛ. ПОБЕДЫ, Д. 21
|
||||||
|
241035, БРЯНСКАЯ ОБЛАСТЬ, Г. БРЯНСК, УЛ. КОМСОМОЛЬСКАЯ, Д. 12
|
||||||
|
241050, БРЯНСКАЯ ОБЛАСТЬ, Г. БРЯНСК, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 41
|
||||||
|
241050, БРЯНСКАЯ ОБЛАСТЬ, Г. БРЯНСК, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 41, ПОМ. IX
|
||||||
|
242500, БРЯНСКАЯ ОБЛАСТЬ, Г. КАРАЧЕВ, УЛ. СОВЕТСКАЯ, Д. 62
|
||||||
|
242600, БРЯНСКАЯ ОБЛАСТЬ, Г. ДЯТЬКОВО, УЛ. ЛЕНИНА, Д. 166
|
||||||
|
243020, БРЯНСКАЯ ОБЛАСТЬ, Г. НОВОЗЫБКОВ, УЛ. ПЕРВОМАЙСКАЯ, Д. 26
|
||||||
|
243140, БРЯНСКАЯ ОБЛАСТЬ, Г. КЛИНЦЫ, ПР-Т ЛЕНИНА, Д. 21
|
||||||
|
243300, БРЯНСКАЯ ОБЛАСТЬ, Г. УНЕЧА, УЛ. ИВАНОВА, Д. 1
|
||||||
|
243400, БРЯНСКАЯ ОБЛАСТЬ, Г. ПОЧЕП, УЛ. ЛЕНИНА, Д. 3
|
||||||
|
248001, РОССИЯ, КАЛУЖСКАЯ ОБЛАСТЬ, Г. КАЛУГА, УЛ. КИРОВА, Д. 14
|
||||||
|
248023, КАЛУЖСКАЯ ОБЛАСТЬ, Г. КАЛУГА, ПЛ. ПОБЕДЫ, Д. 15
|
||||||
|
248023, КАЛУЖСКАЯ ОБЛАСТЬ, Г. КАЛУГА, ПЛ. ПОБЕДЫ, Д. 15, ПОМ. 12
|
||||||
|
248023, КАЛУЖСКАЯ ОБЛАСТЬ, Г. КАЛУГА, ПЛ. ПОБЕДЫ, Д. 15, ПОМ. 13
|
||||||
|
249000, КАЛУЖСКАЯ ОБЛАСТЬ, Г. БАЛАБАНОВО, УЛ. БОРОВСКАЯ, Д. 1
|
||||||
|
249039, КАЛУЖСКАЯ ОБЛАСТЬ, Г. ОБНИНСК, ПР-КТ МАРКСА, Д. 50, ПОМ. 1
|
||||||
|
249096, КАЛУЖСКАЯ ОБЛАСТЬ, Г. МАЛОЯРОСЛАВЕЦ, УЛ. ЛЕНИНА, Д. 20
|
||||||
|
249100, КАЛУЖСКАЯ ОБЛАСТЬ, Г. ТАРУСА, УЛ. ОКТЯБРЬСКАЯ, Д. 4
|
||||||
|
249406, КАЛУЖСКАЯ ОБЛ., Г. ЛЮДИНОВО, УЛ. ЛЕНИНА, Д. 4
|
||||||
|
300026, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ТУЛА, УЛ. СОВЕТСКАЯ, 10, ПОМЕЩЕНИЕ 38А
|
||||||
|
300041, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ТУЛА, УЛИЦА СОВЕТСКАЯ, Д. 10, ПОМ. 31
|
||||||
|
300041, ТУЛЬСКАЯ ОБЛ., Г. ТУЛА, ЦЕНТРАЛЬНЫЙ Р-Н, ПРОСП. ЛЕНИНА, Д. 54А
|
||||||
|
300041, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ТУЛА, КРАСНОАРМЕЙСКИЙ ПР-КТ, Д. 27, КОМ. № 9
|
||||||
|
301260, ТУЛЬСКАЯ ОБЛАСТЬ, Г. КИРЕЕВСК, УЛ. ЛЕНИНА, Д. 16
|
||||||
|
301320, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ВЕНЕВ, УЛ. БУНДУРИНА, ЗД. 10
|
||||||
|
301361, ТУЛЬСКАЯ ОБЛАСТЬ, АЛЕКСИНСКИЙ Р-ОН, Г. АЛЕКСИН, УЛ. МИРА, Д. 20
|
||||||
|
301430, ТУЛЬСКАЯ ОБЛАСТЬ, СУВОРОВСКИЙ Р-Н, Г. СУВОРОВ, ПР-КТ МИРА, Д. 21
|
||||||
|
301470, ТУЛЬСКАЯ ОБЛАСТЬ, ПЛАВСКИЙ Р-Н, Г. ПЛАВСК, УЛ. КОММУНАРОВ, Д. 45
|
||||||
|
301530, ТУЛЬСКАЯ ОБЛАСТЬ, БЕЛЕВСКИЙ Р-Н, Г. БЕЛЕВ, ПЛ. ОКТЯБРЯ, Д. 7
|
||||||
|
301600, ТУЛЬСКАЯ ОБЛАСТЬ, Г. УЗЛОВАЯ, УЛ. ТРЕГУБОВА, Д. 40
|
||||||
|
301650, ТУЛЬСКАЯ ОБЛАСТЬ, НОВОМОСКОВСКИЙ Р-Н, Г. НОВОМОСКОВСК, УЛ. КОМСОМОЛЬСКАЯ/УЛ. ТРУДОВЫЕ РЕЗЕРВЫ, Д. 21/34, ПОМ. 1-4, 6-11
|
||||||
|
301720, ТУЛЬСКАЯ ОБЛАСТЬ, Г. КИМОВСК, УЛ. КРЫЛОВА, Д. 2
|
||||||
|
301764, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ДОНСКОЙ, МКР. ЦЕНТРАЛЬНЫЙ, УЛ. ОКТЯБРЬСКАЯ, Д. 59А
|
||||||
|
301835, ТУЛЬСКАЯ ОБЛАСТЬ, Г. БОГОРОДИЦК, УЛ. КОММУНАРОВ, Д. 59
|
||||||
|
301840, ТУЛЬСКАЯ ОБЛАСТЬ, Г. ЕФРЕМОВ, УЛ. СВЕРДЛОВА, Д. 55
|
||||||
|
302026, ОРЛОВСКАЯ ОБЛАСТЬ, Г. ОРЕЛ, УЛ. КОМСОМОЛЬСКАЯ, Д. 78
|
||||||
|
302026, ОРЛОВСКАЯ ОБЛАСТЬ, Г. ОРЕЛ, УЛ. КОМСОМОЛЬСКАЯ, Д. 78, ПОМ. 14
|
||||||
|
302030, ОРЛОВСКАЯ ОБЛАСТЬ, Г. ОРЕЛ, УЛ. МОСКОВСКАЯ, Д. 28, ПОМ. 87
|
||||||
|
303030, ОРЛОВСКАЯ ОБЛАСТЬ, Г. МЦЕНСК, УЛ. ЛЕНИНА, Д. 25
|
||||||
|
303850, ОРЛОВСКАЯ ОБЛАСТЬ, Г. ЛИВНЫ, УЛ. МАКСИМА ГОРЬКОГО, Д. 2
|
||||||
|
305000, КУРСКАЯ ОБЛАСТЬ, Г. КУРСК, УЛ. ЛЕНИНА, Д. 31, ПОМ. 1Б, КАБ. 17
|
||||||
|
305000, РФ, КУРСКАЯ ОБЛ., Г. КУРСК, УЛ. ЛЕНИНА, Д. 31, ПОМ. 1Б
|
||||||
|
305029, КУРСКАЯ ОБЛАСТЬ, Г. КУРСК, УЛ. КАРЛА МАРКСА, Д. 59А
|
||||||
|
305040, КУРСКАЯ ОБЛАСТЬ, Г. КУРСК, УЛ. СТУДЕНЧЕСКАЯ, Д. 3
|
||||||
|
306230, КУРСКАЯ ОБЛАСТЬ, Г. ОБОЯНЬ, УЛ. 3-ГО ИНТЕРНАЦИОНАЛА, Д. 40
|
||||||
|
307179, КУРСКАЯ ОБЛАСТЬ, Г. ЖЕЛЕЗНОГОРСК, УЛ. ВОИНОВ-ИНТЕРНАЦИОНАЛИСТОВ, Д. 6, ПОМ. 3
|
||||||
|
307250, КУРСКАЯ ОБЛАСТЬ, Г. КУРЧАТОВ, УЛ. САДОВАЯ, ВЛД. 11А
|
||||||
|
308009, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. БЕЛГОРОД, УЛ. ПОПОВА, Д. 12
|
||||||
|
308009, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. БЕЛГОРОД, УЛ. ПОПОВА, Д. 12, КАБ. 1-012
|
||||||
|
308010, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. БЕЛГОРОД, ПР-КТ Б.ХМЕЛЬНИЦКОГО, Д. 164
|
||||||
|
308027, РОССИЙСКАЯ ФЕДЕРАЦИЯ, БЕЛГОРОДСКАЯ ОБЛ., Г. БЕЛГОРОД, УЛ. ЩОРСА, Д. 8
|
||||||
|
308034, Г. БЕЛГОРОД, ПР-КТ ВАТУТИНА, Д. 9Б
|
||||||
|
308034, РОССИЙСКАЯ ФЕДЕРАЦИЯ, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г.БЕЛГОРОД, ПР-КТ ВАТУТИНА, Д. 9Б
|
||||||
|
309070, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. СТРОИТЕЛЬ, УЛ. ЖУКОВА, ЗД. 6В
|
||||||
|
309181, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. ГУБКИН, УЛ. КИРОВА, Д. 53
|
||||||
|
309512, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. СТАРЫЙ ОСКОЛ, МКР. ЖУКОВА, Д. 48, ПОМЕЩЕНИЕ 6
|
||||||
|
309640, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. НОВЫЙ ОСКОЛ, ПЛ. ЦЕНТРАЛЬНАЯ, Д. 1
|
||||||
|
309857, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. АЛЕКСЕЕВКА, УЛ. МОСТОВАЯ, ЗД. 90
|
||||||
|
309996, БЕЛГОРОДСКАЯ ОБЛАСТЬ, Г. ВАЛУЙКИ, УЛ. М. ГОРЬКОГО, Д. 2
|
||||||
|
309996, ТАМБОВСКАЯ ОБЛАСТЬ, Г. КОТОВСК, УЛ. ОКТЯБРЬСКАЯ, Д. 6, ПОМ. 1
|
||||||
|
344000, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, КИРОВСКИЙ РАЙОН, УЛ. КРАСНОАРМЕЙСКАЯ, 206, 2 ЭТАЖ, ПОМ. 2-18
|
||||||
|
344000, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ. КИРОВСКИЙ РАЙОН, УЛ. КРАСНОАРМЕЙСКАЯ, 206
|
||||||
|
344002, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, ПР. БУДЕННОВСКИЙ, Д. 42/50
|
||||||
|
344002, РОССИЯ, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, УЛ. ТЕМЕРНИЦКАЯ, Д. 80/31/81
|
||||||
|
344010, Г. РОСТОВ-НА-ДОНУ, КИРОВСКИЙ РАЙОН, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 170
|
||||||
|
344010, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. РОСТОВ-НА-ДОНУ, ПЛ. ГАГАРИНА, Д. 6/87
|
||||||
|
344037, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, ПРОЛЕТАРСКИЙ РАЙОН, УЛ. 20-Я ЛИНИЯ, Д. 4/3/13
|
||||||
|
344091, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РОСТОВСКАЯ ОБЛ., Г. РОСТОВ-НА-ДОНУ, ПР. КОММУНИСТИЧЕСКИЙ, Д. 27
|
||||||
|
344092, Г. РОСТОВ-НА-ДОНУ, ПР. КОСМОНАВТОВ, 15
|
||||||
|
344113, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РОСТОВСКАЯ ОБЛ., Г. РОСТОВ-НА-ДОНУ, ПР. КОРОЛЕВА, Д. 14 А
|
||||||
|
344116, РОСТОВСКАЯ ОБЛАСТЬ, Г. РОСТОВ-НА-ДОНУ, ПР-КТ СТАЧКИ, ЗД. 25
|
||||||
|
346130, РОСТОВСКАЯ ОБЛАСТЬ, Г. МИЛЛЕРОВО, УЛ. РОССИЙСКАЯ, ЗД. 66
|
||||||
|
346330, РОСТОВСКАЯ ОБЛАСТЬ, Г. ДОНЕЦК, УЛ. МАКСИМА ГОРЬКОГО, Д. 67
|
||||||
|
346350, РОСТОВСКАЯ ОБЛАСТЬ, КРАСНОСУЛИНСКИЙ Р-Н, Г. КРАСНЫЙ СУЛИН, УЛ. ПЕРВОМАЙСКАЯ, Д. 10/12
|
||||||
|
346400, РОСТОВСКАЯ ОБЛАСТЬ, Г. НОВОЧЕРКАССК, УЛ. МОСКОВСКАЯ, Д. 1 / ПР-Т ПЛАТОВСКИЙ, Д. 90
|
||||||
|
346630, РОСТОВСКАЯ ОБЛАСТЬ, Г. СЕМИКАРАКОРСК, УЛ. ЛЕНИНА, Д. 112
|
||||||
|
346720, РОСТОВСКАЯ ОБЛАСТЬ, АКСАЙСКИЙ Р-Н, Г. АКСАЙ, ПР-КТ ЛЕНИНА, Д. 26
|
||||||
|
346787, РОСТОВСКАЯ ОБЛ., Г. АЗОВ, УЛ. МИРА, Д. 45/64
|
||||||
|
346880, РОСТОВСКАЯ ОБЛАСТЬ, Г.О. ГОРОД БАТАЙСК, Г. БАТАЙСК, УЛ. ЭНГЕЛЬСА, Д. 182
|
||||||
|
346918, РОСТОВСКАЯ ОБЛ., Г. НОВОШАХТИНСК, ПР-КТ ЛЕНИНА, Д. 5-А
|
||||||
|
347045, РОСТОВСКАЯ ОБЛАСТЬ, Г. БЕЛАЯ КАЛИТВА, УЛ. ЭНГЕЛЬСА, Д. 382
|
||||||
|
347382, РОСТОВСКАЯ ОБЛАСТЬ, Г. ВОЛГОДОНСК, ПР-КТ СТРОИТЕЛЕЙ, Д. 21, ПОМ. 2
|
||||||
|
347630, РОСТОВСКАЯ ОБЛАСТЬ, Г. САЛЬСК, УЛ. ПУШКИНА, Д. 15
|
||||||
|
347810, РОСТОВСКАЯ ОБЛАСТЬ, Г. КАМЕНСК-ШАХТИНСКИЙ, ПР-Т КАРЛА МАРКСА, Д. 73
|
||||||
|
347871, РОСТОВСКАЯ ОБЛАСТЬ, Г. ГУКОВО, УЛ. КАРЛА МАРКСА, Д. 72
|
||||||
|
347904, РОСТОВСКАЯ ОБЛАСТЬ, Г. ТАГАНРОГ, УЛ. ДЗЕРЖИНСКОГО/УЛ.ФРУНЗЕ, Д. 1/100
|
||||||
|
347922, РОСТОВСКАЯ ОБЛАСТЬ, Г. ТАГАНРОГ, ПЕР. 1-Й КРЕПОСТНОЙ, Д. 17
|
||||||
|
350000, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. КРАСНОДАР, УЛ. СЕДИНА, Д. 159
|
||||||
|
350001, Г. КРАСНОДАР, ЦЕНТРАЛЬНЫЙ ОКРУГ, УЛ. СТАВРОПОЛЬСКАЯ, ДОМ 184
|
||||||
|
350015, КРАСНОДАРСКИЙ КРАЙ, Г. КРАСНОДАР, УЛ. КРАСНАЯ, Д. 126
|
||||||
|
350015, КРАСНОДАРСКИЙ КРАЙ, Г. КРАСНОДАР, УЛ. КРАСНАЯ, Д. 176/1
|
||||||
|
350049, Г. КРАСНОДАР, ПРИКУБАНСКИЙ ОКРУГ, УЛ. ИМ. ТУРГЕНЕВА, ДОМ 107
|
||||||
|
350049, Г. КРАСНОДАР, УЛ. ТУРГЕНЕВА, ДОМ 107, ЛИТЕР А
|
||||||
|
350049, КРАСНОДАРСКИЙ КРАЙ, Г. КРАСНОДАР, УЛ. СЕВЕРНАЯ, Д. 233, ЧАСТЬ ПОМЕЩЕНИЯ №17
|
||||||
|
350063, Г. КРАСНОДАР, ЗАПАДНЫЙ ВНУТРИГОРОДСКОЙ ОКРУГ, УЛ. ИМ. ПУШКИНА, ДОМ №22
|
||||||
|
350075, КРАСНОДАРСКИЙ КРАЙ, Г. КРАСНОДАР, УЛ. ИМ. СЕЛЕЗНЕВА, Д. 126
|
||||||
|
350901, Г. КРАСНОДАР, ПРИКУБАНСКИЙ ОКРУГ, УЛ. 1 МАЯ, Д. 158.
|
||||||
|
352120, КРАСНОДАРСКИЙ КРАЙ, Г. ТИХОРЕЦК, УЛ. ЭНГЕЛЬСА, Д. 101
|
||||||
|
352192, КРАСНОДАРСКИЙ КРАЙ, Г. ГУЛЬКЕВИЧИ, УЛ. ЛЕНИНГРАДСКАЯ, Д. 3
|
||||||
|
352240, КРАСНОДАРСКИЙ КРАЙ, Г. НОВОКУБАНСК, УЛ. ПЕРВОМАЙСКАЯ, Д. 161
|
||||||
|
352330, КРАСНОДАРСКИЙ КРАЙ, Г. УСТЬ-ЛАБИНСК, УЛ. ОКТЯБРЬСКАЯ, Д. 99, ПОМ. 1-003
|
||||||
|
352380, КРАСНОДАРСКИЙ КРАЙ, Г. КРОПОТКИН, УЛ. КРАСНАЯ, Д. 96
|
||||||
|
352430, КРАСНОДАРСКИЙ КРАЙ, Г. КУРГАНИНСК, УЛ. МИЛИЦИОНЕРСКАЯ, Д. 62А
|
||||||
|
352500, КРАСНОДАРСКИЙ КРАЙ, Г. ЛАБИНСК, УЛ. ХАЛТУРИНА, Д. 18
|
||||||
|
352630, КРАСНОДАРСКИЙ КРАЙ, Г. БЕЛОРЕЧЕНСК, УЛ. ЛЕНИНА, Д. 145
|
||||||
|
352690, КРАСНОДАРСКИЙ КРАЙ, Г. АПШЕРОНСК, УЛ. ВОРОШИЛОВА, Д. 8А
|
||||||
|
352700, КРАСНОДАРСКИЙ КРАЙ, Г. ТИМАШЕВСК, УЛ. КРАСНАЯ, Д. 106
|
||||||
|
352800, КРАСНОДАРСКИЙ КРАЙ, ТУАПСИНСКИЙ РАЙОН, Г. ТУАПСЕ, УЛ. С. ПЕРОВСКОЙ, 2, ПОМ. 21, 28-30
|
||||||
|
352900, КРАСНОДАРСКИЙ КРАЙ, Г. АРМАВИР, УЛ. ЛЕНИНА, Д. 101А
|
||||||
|
353180, КРАСНОДАРСКИЙ КРАЙ, Г. КОРЕНОВСК, УЛ. КРАСНАЯ, Д. 112, ПОМ. № 1
|
||||||
|
353204, КРАСНОДАРСКИЙ КРАЙ, СТАНИЦА ДИНСКАЯ, УЛ. ЧАПАЕВА, Д. 83
|
||||||
|
353290, КРАСНОДАРСКИЙ КРАЙ, Г. ГОРЯЧИЙ КЛЮЧ, УЛ. ЛЕНИНА, Д. 192
|
||||||
|
353320, КРАСНОДАРСКИЙ КРАЙ, Г. АБИНСК, ПР-Т КОМСОМОЛЬСКИЙ, Д. 78
|
||||||
|
353380, КРАСНОДАРСКИЙ КРАЙ, Г. КРЫМСК, УЛ. СИНЕВА, Д. 9Б
|
||||||
|
353440, КРАСНОДАРСКИЙ КРАЙ, Г. АНАПА, УЛ. КРЫМСКАЯ, Д. 172
|
||||||
|
353460, КРАСНОДАРСКИЙ КРАЙ, Г. ГЕЛЕНДЖИК, УЛ. ХЕРСОНСКАЯ, Д. 23
|
||||||
|
353500, КРАСНОДАРСКИЙ КРАЙ, Г. ТЕМРЮК, УЛ. ЛЕНИНА, Д. 68
|
||||||
|
353560, КРАСНОДАРСКИЙ КРАЙ, Г. СЛАВЯНСК-НА-КУБАНИ, УЛ. КОВТЮХА, Д. 17
|
||||||
|
353680, КРАСНОДАРСКИЙ КРАЙ, Г. ЕЙСК, УЛ. ПОБЕДЫ, Д. 78
|
||||||
|
353730, КРАСНОДАРСКИЙ КРАЙ, СТ-ЦА КАНЕВСКАЯ, УЛ. ВОКЗАЛЬНАЯ, Д. 59
|
||||||
|
353905, КРАСНОДАРСКИЙ КРАЙ, Г. НОВОРОССИЙСК, УЛ. ТОЛСТОГО, Д. 2Г
|
||||||
|
353915, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. НОВОРОССИЙСК, ПРОСП. ДЗЕРЖИНСКОГО, Д. 205 А
|
||||||
|
354000, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, ЦЕНТРАЛЬНЫЙ Р-Н, УЛ. СЕВЕРНАЯ, Д. 6
|
||||||
|
354000, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, ЦЕНТРАЛЬНЫЙ РАЙОН, УЛ. КОНСТИТУЦИИ СССР, Д. 18, ПОМ. 14-24
|
||||||
|
354002, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, ХОСТИНСКИЙ РАЙОН, УЛ. УЧИТЕЛЬСКАЯ, ДОМ 3
|
||||||
|
354200, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, ЛАЗАРЕВСКИЙ Р-Н, УЛ. КАЛАРАША, Д. 111А, КОРП. 1
|
||||||
|
354340, КРАСНОДАРСКИЙ КРАЙ, Г. СОЧИ, АДЛЕРСКИЙ, РАЙОН, УЛ. МОЛОКОВА, Д. 18/78
|
||||||
|
355003, Г. СТАВРОПОЛЬ, УЛ. ЛЕНИНА, Д. 299
|
||||||
|
355003, СТАВРОПОЛЬСКИЙ КРАЙ, Г. СТАВРОПОЛЬ, УЛ. ЛЕНИНА, Д. 299, ПОМ. 8
|
||||||
|
355035, РФ, СТАВРОПОЛЬСКИЙ КРАЙ, Г. СТАВРОПОЛЬ, УЛ. ДЗЕРЖИНСКОГО, Д. 114, 1 ЭТАЖ, ЧАСТЬ ПОМ. № 145 (167,98 КВ. М.), ЧАСТЬ ПОМ. № 146 (1,43 КВ. М), ПОМ. № 147 (1,6 КВ. М), ЧАСТЬ ПОМ. № 148 (4,26 КВ. М)
|
||||||
|
355040, СТАВРОПОЛЬСКИЙ КРАЙ, Г. СТАВРОПОЛЬ, УЛ. 50-ЛЕТ ВЛКСМ, Д. 11, К. 1
|
||||||
|
356003, СТАВРОПОЛЬСКИЙ КРАЙ, Г. НОВОАЛЕКСАНДРОВСК, УЛ. ЖЕЛЕЗНОДОРОЖНАЯ, Д. 85А
|
||||||
|
356140, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ИЗОБИЛЬНЫЙ, ПЕР. ЛЕНИНА, Д. 15
|
||||||
|
356240, СТАВРОПОЛЬСКИЙ КРАЙ, Г. МИХАЙЛОВСК, УЛ. ВОЙКОВА, Д. 454/1
|
||||||
|
356420, СТАВРОПОЛЬСКИЙ КРАЙ, Г. БЛАГОДАРНЫЙ, УЛ. ТОЛСТОГО, Д. 86А
|
||||||
|
356530, СТАВРОПОЛЬСКИЙ КРАЙ, Г. СВЕТЛОГРАД, УЛ. КОМСОМОЛЬСКАЯ, ЗД. 58/1
|
||||||
|
356630, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ИПАТОВО, УЛ. СВЕРДЛОВА, ЗД. 18
|
||||||
|
356800, СТАВРОПОЛЬСКИЙ КРАЙ, Г. БУДЕННОВСК, УЛ. ОКТЯБРЬСКАЯ, Д. 79
|
||||||
|
357100, СТАВРОПОЛЬСКИЙ КРАЙ, Г. НЕВИННОМЫССК, УЛ. ГАГАРИНА, ДОМ 70Б
|
||||||
|
357207, СТАВРОПОЛЬСКИЙ КРАЙ, Г. МИНЕРАЛЬНЫЕ ВОДЫ, ПР-КТ 22 ПАРТСЪЕЗДА, ВЛД. 94/1
|
||||||
|
357400, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ЖЕЛЕЗНОВОДСК, УЛ. ЛЕНИНА, Д. 55
|
||||||
|
357500, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ПЯТИГОРСК, УЛ. ОКТЯБРЬСКАЯ, Д. 1
|
||||||
|
357600, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ЕССЕНТУКИ, ПРИВОКЗАЛЬНАЯ ПЛОЩАДЬ, ПОМ. 1К
|
||||||
|
357700, СТАВРОПОЛЬСКИЙ КРАЙ, Г. КИСЛОВОДСК, УЛ. ГОРЬКОГО, 17Б
|
||||||
|
357821, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ГЕОРГИЕВСК, УЛ. САЛОГУБОВА, Д. 16
|
||||||
|
357911, СТАВРОПОЛЬСКИЙ КРАЙ, Г. ЗЕЛЕНОКУМСК, УЛ. СОВЕТСКАЯ, Д. 11
|
||||||
|
360051, КАБАРДИНО-БАЛКАРСКАЯ РЕСПУБЛИКА, Г. НАЛЬЧИК, ПР-КТ ШОГЕНЦУКОВА, Д. 21
|
||||||
|
362008, РЕСПУБЛИКА СЕВЕРНАЯ ОСЕТИЯ-АЛАНИЯ, Г. ВЛАДИКАВКАЗ, УЛ. ГЕНЕРАЛА ПЛИЕВА, Д. 10
|
||||||
|
364024, ЧЕЧЕНСКАЯ РЕСПУБЛИКА, Г. ГРОЗНЫЙ, УЛ. ИМ С.Ш. ЛОРСАНОВА, Д. 14
|
||||||
|
367026, РЕСПУБЛИКА ДАГЕСТАН, Г. МАХАЧКАЛА, ПР-КТ ИМАМА ШАМИЛЯ, Д. 36К
|
||||||
|
385000, РЕСПУБЛИКА АДЫГЕЯ (АДЫГЕЯ), Г. МАЙКОП, УЛ. ЖУКОВСКОГО/СОВЕТСКАЯ, 20А/199
|
||||||
|
386102, РЕСПУБЛИКА ИНГУШЕТИЯ, Г. НАЗРАНЬ, УЛ. МОСКОВСКАЯ, Д. 28
|
||||||
|
390000, Г. РЯЗАНЬ, УЛ. ЛЕНИНА, Д.9
|
||||||
|
390000, РФ, РЯЗАНСКАЯ ОБЛ., Г. РЯЗАНЬ, УЛ. ЛЕНИНА, Д. 9, ПОМ. 1 (13,39 КВ.М.)
|
||||||
|
390013, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РЯЗАНСКАЯ ОБЛ., Г. РЯЗАНЬ, ПЕРВОМАЙСКИЙ ПР-КТ, Д. 39/2, ПОМ. Н 10
|
||||||
|
390013, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РЯЗАНСКАЯ ОБЛ., Г. РЯЗАНЬ, ПЕРВОМАЙСКИЙ ПРОСП., Д. 39/2, ПОМ. Н 10
|
||||||
|
391300, РЯЗАНСКАЯ ОБЛ., Г. КАСИМОВ, УЛ. СОВЕТСКАЯ, Д. 11
|
||||||
|
391434, РЯЗАНСКАЯ ОБЛАСТЬ, Г. САСОВО, МИКРОРАЙОН СЕВЕРНЫЙ, ЗДАНИЕ 12Г
|
||||||
|
391803, РЯЗАНСКАЯ ОБЛАСТЬ, Г. СКОПИН, УЛ. К.МАРКСА, Д. 178
|
||||||
|
391964, РЯЗАНСКАЯ ОБЛАСТЬ, Г. РЯЖСК, УЛ. М.ГОРЬКОГО, Д. 12
|
||||||
|
392000, ТАМБОВСКАЯ ОБЛАСТЬ, Г. ТАМБОВ, УЛ. СОВЕТСКАЯ/МАКСИМА ГОРЬКОГО, Д. 104/14
|
||||||
|
392000, ТАМБОВСКАЯ ОБЛАСТЬ, Г. ТАМБОВ, УЛ. СОВЕТСКАЯ/МАКСИМА ГОРЬКОГО, Д. 104/14, ПОМ. 1-008
|
||||||
|
393250, ТАМБОВСКАЯ ОБЛАСТЬ, Г. РАССКАЗОВО, УЛ. КУЙБЫШЕВСКИЙ ПРОЕЗД, Д. 2Г
|
||||||
|
393760, ТАМБОВСКАЯ ОБЛАСТЬ, Г. МИЧУРИНСК, УЛ. СОВЕТСКАЯ, Д. 323
|
||||||
|
393950, ТАМБОВСКАЯ ОБЛАСТЬ, ГОРОД МОРШАНСК, УЛИЦА ИНТЕРНАЦИОНАЛЬНАЯ, ДОМ 49
|
||||||
|
394007, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, ЛЕНИНСКИЙ ПРОСПЕКТ, Д. 116А
|
||||||
|
394018, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. КИРОВА, Д. 11, ПОМ. 123, 386
|
||||||
|
394018, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. КИРОВА, Д. 11, ПОМ. 22-48
|
||||||
|
394018, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. КИРОВА, Д. 11, ПОМ. 23-30
|
||||||
|
394018, Г. ВОРОНЕЖ, УЛ. КИРОВА, Д. 11
|
||||||
|
394030, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. КОЛЬЦОВСКАЯ, Д. 31, ПОМ. III, КОМН. 1, 2, 4-17
|
||||||
|
394033, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. ОСТУЖЕВА, Д. 64
|
||||||
|
394053, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, УЛ. ЗАГОРОВСКОГО, Д. 1
|
||||||
|
394088, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, КОМИНТЕРНОВСКИЙ РАЙОН, УЛ. ГЕНЕРАЛА ЛИЗЮКОВА, Д. 50 (ЧАСТЬ ПОМЕЩЕНИЯ 2)
|
||||||
|
394088, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ВОРОНЕЖ, КОМИНТЕРНОВСКИЙ РАЙОН, УЛ. ГЕНЕРАЛА ЛИЗЮКОВА, Д.50
|
||||||
|
396005, ВОРОНЕЖСКАЯ ОБЛАСТЬ, РАМОНСКИЙ РАЙОН, ЯМЕНСКОЕ СЕЛЬСКОЕ ПОСЕЛЕНИЕ, П. СОЛНЕЧНЫЙ, УЛ. МОСКОВСКОЕ ШОССЕ, Д. 22
|
||||||
|
396072, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. НОВОВОРОНЕЖ, УЛ. КОСМОНАВТОВ, Д. 23/1
|
||||||
|
396310, ВОРОНЕЖСКАЯ ОБЛАСТЬ, С. НОВАЯ УСМАНЬ, УЛ. ЛЕНИНА, Д. 275, ПОМ. 1
|
||||||
|
396420, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ПАВЛОВСК, МИКРОРАЙОН СЕВЕРНЫЙ, ЗД. 23
|
||||||
|
396658, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. РОССОШЬ, ПРОСПЕКТ ТРУДА, 4Б
|
||||||
|
396790, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. БОГУЧАР, ПЛ МАЛАХОВСКОГО, Д 12/1
|
||||||
|
396900, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. СЕМИЛУКИ, УЛ. ДЗЕРЖИНСКОГО, ЗД. 10А
|
||||||
|
397160, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. БОРИСОГЛЕБСК, УЛ. СВОБОДЫ/УЛ. СОВЕТСКАЯ, Д. 213/8
|
||||||
|
397500, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. БУТУРЛИНОВКА, ПЛ. ВОЛИ, Д. 6
|
||||||
|
397700, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. БОБРОВ, УЛ. 22 ЯНВАРЯ, Д. 71/8
|
||||||
|
397855, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ОСТРОГОЖСК, УЛ. ЛЕНИНА, Д. 31М
|
||||||
|
397901, ВОРОНЕЖСКАЯ ОБЛАСТЬ, Г. ЛИСКИ, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 36
|
||||||
|
398016, ЛИПЕЦКАЯ ОБЛАСТЬ, Г.ЛИПЕЦК, УЛ.КОСМОНАВТОВ, Д.16./1
|
||||||
|
398024, ЛИПЕЦКАЯ ОБЛАСТЬ, Г. ЛИПЕЦК, ПРОСПЕКТ ПОБЕДЫ, Д. 20, ПОМ. 3
|
||||||
|
398027, ЛИПЕЦКАЯ ОБЛАСТЬ, Г. ЛИПЕЦК, УЛ. ГЕРОЯ РОССИИ ЭДУАРДА БЕЛАНА, Д. 10А
|
||||||
|
398046, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ЛИПЕЦКАЯ ОБЛ., Г. ЛИПЕЦК, УЛ. ИМ. ГЕНЕРАЛА МЕРКУЛОВА, Д. 10 А, ПОМ. 1
|
||||||
|
398059, Г. ЛИПЕЦК, УЛ. БАРАШЕВА, Д. 7. ПОМЕЩЕНИЯ ВТОРОГО ЭТАЖА: № 2-010; ТРЕТЬЕГО ЭТАЖА: №№ 01-12; ЧЕТВЕРТОГО ЭТАЖА: №№ 01-12
|
||||||
|
398059, Г. ЛИПЕЦК, УЛ. БАРАШЕВА, Д. 7. ПОМЕЩЕНИЯ ПОДВАЛА: №№ 10-11; ПЕРВОГО ЭТАЖА: №№ 6-7.
|
||||||
|
398059, Г. ЛИПЕЦК, УЛ. БАРАШЕВА, Д.7
|
||||||
|
399050, ЛИПЕЦКАЯ ОБЛАСТЬ, Г. ГРЯЗИ, УЛ. РЕВОЛЮЦИИ 1905 ГОДА, Д. 12/26
|
||||||
|
399610, ЛИПЕЦКАЯ ОБЛАСТЬ, ЛЕБЕДЯНСКИЙ Р-Н, Г. ЛЕБЕДЯНЬ, УЛ. ПОЧТОВАЯ, ЗД. 31/1
|
||||||
|
399770, ЛИПЕЦКАЯ ОБЛАСТЬ, ГОРОД ЕЛЕЦ, УЛИЦА МИРА, ДОМ 133
|
||||||
|
400066, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛГОГРАД, ПР. ИМЕНИ В.И. ЛЕНИНА, Д. 2
|
||||||
|
400066, Г. ВОЛГОГРАД УЛ. КОМСОМОЛЬСКАЯ, Д.10
|
||||||
|
400112, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛГОГРАД, ПРОСПЕКТ ИМ. ГЕРОЕВ СТАЛИНГРАДА, Д. 49
|
||||||
|
400117, РОССИЯ, ВОЛГОГРАДСКАЯ ОБЛ., Г. ВОЛГОГРАД, УЛ. 8-Й ВОЗДУШНОЙ АРМИИ, Д. 47 А
|
||||||
|
400121, ВОЛГОГРАДСКАЯ ОБЛ., Г. ВОЛГОГРАД, УЛ. НИКОЛАЯ ОТРАДЫ, Д. 6
|
||||||
|
400123, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛГОГРАД, УЛ. ИМ. МАРШАЛА ЕРЕМЕНКО, Д. 124
|
||||||
|
403003, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, ГОРОДИЩЕНСКИЙ Р-Н, Р.П. ГОРОДИЩЕ, ПР-КТ ЛЕНИНА, Д. 6, ПОМ. 5
|
||||||
|
403113, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. УРЮПИНСК, УЛ. СОВЕТСКАЯ, Д. 2
|
||||||
|
403343, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. МИХАЙЛОВКА, УЛ. ОБОРОНЫ, Д. 44/53
|
||||||
|
403540, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ФРОЛОВО, УЛ. ФРОЛОВСКАЯ, Д. 5
|
||||||
|
403873, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. КАМЫШИН, УЛ. ПРОЛЕТАРСКАЯ, 101, ПРИМЕРНО В 60 МЕТРАХ ПО НАПРАВЛЕНИЮ НА СЕВЕРО-ЗАПАД
|
||||||
|
404105, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. ВОЛЖСКИЙ, УЛ. МИРА, Д. №74 ПОМ. 1 ПОМЕЩЕНИЯ ПЕРВОГО ЭТАЖА:№1, ЧАСТЬ ПОМ. №2, №№3-12, ЧАСТЬ ПОМ. №13, ПОМ. №№ 15-16
|
||||||
|
404503, ВОЛГОГРАДСКАЯ ОБЛАСТЬ, Г. КАЛАЧ-НА-ДОНУ, УЛ. ОКТЯБРЬСКАЯ, Д. 260А
|
||||||
|
410012, РОССИЙСКАЯ ФЕДЕРАЦИЯ, САРАТОВСКАЯ ОБЛАСТЬ, Г. САРАТОВ, УЛ. ИМ. ВАСИЛИЯ ЛЮКШИНА, ЗДАНИЕ № 5
|
||||||
|
410012, САРАТОВСКАЯ ОБЛАСТЬ, Г. САРАТОВ, УЛ. МОСКОВСКАЯ, Д. 66, ПОМ. 1
|
||||||
|
410041, Г. САРАТОВ, ПР-Т СТРОИТЕЛЕЙ, Д. 19
|
||||||
|
412031, САРАТОВСКАЯ ОБЛАСТЬ, Г. РТИЩЕВО, УЛ. ЖЕЛЕЗНОДОРОЖНАЯ, Д. 48/1
|
||||||
|
412309, САРАТОВСКАЯ ОБЛАСТЬ, Г. БАЛАШОВ, УЛ. ЛЕНИНА, Д. 21Б, ПОМ. 4
|
||||||
|
412484, САРАТОВСКАЯ ОБЛАСТЬ, Г. КАЛИНИНСК, УЛ. СОВЕТСКАЯ, Д. 26
|
||||||
|
412906, САРАТОВСКАЯ ОБЛАСТЬ, Г. ВОЛЬСК, ПЛ. 10-ЛЕТИЯ ОКТЯБРЯ, Д. 4/1
|
||||||
|
413093, САРАТОВСКАЯ ОБЛАСТЬ, Г. МАРКС, УЛ. ОКТЯБРЬСКАЯ, Д. 53
|
||||||
|
413720, САРАТОВСКАЯ ОБЛАСТЬ, М.Р-Н ПУГАЧЕВСКИЙ, Г. ПУГАЧЕВ, УЛ. МАКСИМА ГОРЬКОГО, Д. 21/3
|
||||||
|
413860, САРАТОВСКАЯ ОБЛАСТЬ, Г. БАЛАКОВО, УЛ. ТРНАВСКАЯ, Д. 23
|
||||||
|
414000, Г. АСТРАХАНЬ, УЛ. КИРОВА, Д. 12/2
|
||||||
|
414040, АСТРАХАНСКАЯ ОБЛАСТЬ, Г. АСТРАХАНЬ, ПЛ. ВОКЗАЛЬНАЯ, СТР. 20Б
|
||||||
|
416506, АСТРАХАНСКАЯ ОБЛАСТЬ, Г. АХТУБИНСК, УЛ. НЕСТЕРОВА, Д. 8
|
||||||
|
416550, АСТРАХАНСКАЯ ОБЛАСТЬ, Г. ЗНАМЕНСК, ПР-КТ 9 МАЯ, Д. 16, СТР. 1
|
||||||
|
420061, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, УЛ. НИКОЛАЯ ЕРШОВА, Д.29"Б", ЭТАЖ 1, КАБИНЕТЫ С №1 ПО №15
|
||||||
|
420080, РЕСПУБЛИКА ТАТАРСТАН, Г.КАЗАНЬ, УЛ.ДЕКАБРИСТОВ, Д.129
|
||||||
|
420100, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, УЛ. ЗАКИЕВА, Д. 1
|
||||||
|
420107, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, УЛ. ОСТРОВСКОГО, Д. 103
|
||||||
|
420110, РЕСПУБЛИКА ТАТАРСТАН, Г.КАЗАНЬ, УЛ.РИХАРДА ЗОРГЕ, Д.75
|
||||||
|
420111, РФ, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, УЛ. МОСКОВСКАЯ, Д. 19/8
|
||||||
|
420126, РОССИЯ, РЕСПУБЛИКА ТАТАРСТАН, Г. КАЗАНЬ, ПР-Т ЯМАШЕВА, Д. 82
|
||||||
|
422000, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. АРСК, ПЛ. СОВЕТСКАЯ, Д. 23
|
||||||
|
422110, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. КУКМОР, УЛ. ЛЕНИНА, Д. 11А
|
||||||
|
422430, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. БУИНСК, УЛ. ЕФРЕМОВА, Д. 146, КВ. 4
|
||||||
|
422550, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. ЗЕЛЕНОДОЛЬСК, УЛ. ЛЕНИНА, Д. 25
|
||||||
|
423040, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. НУРЛАТ, УЛ. К.МАРКСА, Д. 27
|
||||||
|
423236, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. БУГУЛЬМА, УЛ. ГАЗИНУРА ГАФИАТУЛЛИНА, Д. 31
|
||||||
|
423250, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. ЛЕНИНОГОРСК, ПР-КТ 50 ЛЕТ ПОБЕДЫ, ЗД. 11А
|
||||||
|
423330, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. АЗНАКАЕВО, УЛ. ЛЕНИНА, Д. 3А
|
||||||
|
423452, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. АЛЬМЕТЬЕВСК, УЛ. ЛЕНИНА, 29
|
||||||
|
423520, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. ЗАИНСК, УЛ. ЧАПАЕВА, Д. 2, СТР.1.
|
||||||
|
423571, РЕСПУБЛИКА ТАТАРСТАН, НИЖНЕКАМСКИЙ Р-Н, Г. НИЖНЕКАМСК, ПР. МИРА, 61Г
|
||||||
|
423603, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. ЕЛАБУГА, ПР-КТ НЕФТЯНИКОВ, Д. 17
|
||||||
|
423803, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. НАБЕРЕЖНЫЕ ЧЕЛНЫ, НАБЕРЕЖНОЧЕЛНИНСКИЙ ПР-КТ, Д. 7
|
||||||
|
423812, РЕСПУБЛИКА ТАТАРСТАН, Г.НАБЕРЕЖНЫЕ ЧЕЛНЫ, ПРОСПЕКТ МИРА, Д24М (7/20)
|
||||||
|
423831, РЕСПУБЛИКА ТАТАРСТАН, Г. НАБЕРЕЖНЫЕ ЧЕЛНЫ, ПРОСП. ХАСАНА ТУФАНА, Д. 29Б, ПОМ. 37-41, 45
|
||||||
|
423930, РЕСПУБЛИКА ТАТАРСТАН (ТАТАРСТАН), Г. БАВЛЫ, УЛ ПИОНЕРСКАЯ, Д. 13
|
||||||
|
424003, РФ, РЕСПУБЛИКА МАРИЙ ЭЛ, Г. ЙОШКАР-ОЛА, УЛ. ПЕРВОМАЙСКАЯ, Д. 164
|
||||||
|
424033, РЕСПУБЛИКА МАРИЙ ЭЛ, Г. ЙОШКАР-ОЛА, УЛ. ЭШКИНИНА, Д. 10Д
|
||||||
|
425000, РЕСПУБЛИКА МАРИЙ ЭЛ, Г. ВОЛЖСК, УЛ. ЛЕНИНА, Д. 56Б
|
||||||
|
425200, РЕСПУБЛИКА МАРИЙ ЭЛ, ПГТ. МЕДВЕДЕВО, УЛ. СОВЕТСКАЯ, Д. 24Б
|
||||||
|
425400, РЕСПУБЛИКА МАРИЙ ЭЛ, ПГТ. СОВЕТСКИЙ, УЛ. ПОБЕДЫ, ЗД. 22А
|
||||||
|
426004, УДМУРТСКАЯ РЕСПУБЛИКА, Г. ИЖЕВСК, УЛ. ЛЕНИНА, 21, ПОМ. 25, 26, 27 ,28, 29, 30
|
||||||
|
426008, УДМУРТСКАЯ РЕСПУБЛИКА, Г.ИЖЕВСК, УЛ. ПУШКИНСКАЯ, Д.216
|
||||||
|
426073, РОССИЙСКАЯ ФЕДЕРАЦИЯ, УДМУРТСКАЯ РЕСПУБЛИКА, Г. ИЖЕВСК, УЛ. МОЛОДЕЖНАЯ, Д. 87
|
||||||
|
426076, РФ, УДМУРТСКАЯ РЕСПУБЛИКА, Г. ИЖЕВСК, УЛ. ПУШКИНСКАЯ, Д. 173А
|
||||||
|
427433, УДМУРТСКАЯ РЕСПУБЛИКА, Г. ВОТКИНСК, УЛ. 1 МАЯ, Д. 102
|
||||||
|
427620, УДМУРТСКАЯ РЕСПУБЛИКА, Г.ГЛАЗОВ, УЛ.КИРОВА,Д.13
|
||||||
|
427790, УДМУРТСКАЯ РЕСПУБЛИКА, Г. МОЖГА, УЛ. МОЖГИНСКАЯ, Д. 51
|
||||||
|
427960, УДМУРТСКАЯ РЕСПУБЛИКА, Г. САРАПУЛ, УЛ. ГОРЬКОГО, Д. 15А, СТР. 1
|
||||||
|
429335, ЧУВАШСКАЯ РЕСПУБЛИКА-ЧУВАШИЯ, Г. КАНАШ, УЛ. ЖЕЛЕЗНОДОРОЖНАЯ, Д. 87
|
||||||
|
429820, ЧУВАШСКАЯ РЕСПУБЛИКА-ЧУВАШИЯ, Г. АЛАТЫРЬ, УЛ. КОМИССАРИАТСКАЯ, Д. 40 Б
|
||||||
|
429965, ЧУВАШСКАЯ РЕСПУБЛИКА-ЧУВАШИЯ, Г. НОВОЧЕБОКСАРСК, УЛ. СОВЕТСКАЯ, Д. 15
|
||||||
|
430005, РЕСПУБЛИКА МОРДОВИЯ, ЛЕНИНСКИЙ Р-Н, Г. САРАНСК, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 52
|
||||||
|
430027, РЕСПУБЛИКА МОРДОВИЯ, Г. САРАНСК, УЛ. ГАГАРИНА, Д. 99Е
|
||||||
|
431440, РЕСПУБЛИКА МОРДОВИЯ, Г. РУЗАЕВКА, ПРИВОКЗАЛЬНАЯ ПЛОЩАДЬ, Д. 4
|
||||||
|
432017, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Г. УЛЬЯНОВСК, УЛИЦА ГОНЧАРОВА, Д. 25
|
||||||
|
432045, УЛЬЯНОВСКАЯ ОБЛ., Г. УЛЬЯНОВСК, УЛ. ГЕРАСИМОВА, Д. 10, СТР. 4, ПОМ. 106
|
||||||
|
432067, РОССИЯ, УЛЬЯНОВСКАЯ ОБЛ., Г. УЛЬЯНОВСК, УЛЬЯНОВСКИЙ ПРОСП., Д. 6
|
||||||
|
433300, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Г. НОВОУЛЬЯНОВСК, УЛ. КОМСОМОЛЬСКАЯ, Д. 20/13
|
||||||
|
433512, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Г. ДИМИТРОВГРАД, ПРОСП. ЛЕНИНА, ЗДАНИЕ 39Д, ПОМЕЩ. 1
|
||||||
|
433750, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Г. БАРЫШ, УЛ. 45 СТРЕЛКОВОЙ ДИВИЗИИ, ЗД.1Г
|
||||||
|
433870, УЛЬЯНОВСКАЯ ОБЛАСТЬ, Р.П. НОВОСПАССКОЕ, УЛ. МИРА, ЗД. 17
|
||||||
|
440000, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. ПЕНЗА, ЛЕНИНСКИЙ РАЙОН, УЛ. МОСКОВСКАЯ, Д. 73
|
||||||
|
440008, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. ПЕНЗА, УЛ. СУВОРОВА, Д. 139
|
||||||
|
440062, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. ПЕНЗА, ПРОСПЕКТ СТРОИТЕЛЕЙ, Д. 40
|
||||||
|
440068, ПЕНЗЕНСКАЯ ОБЛАСТЬ, С. ЗАСЕЧНОЕ, УЛ. НОВО-ТЕРНОВСКАЯ, Д.1, ПОМ.1
|
||||||
|
442246, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. КАМЕНКА, УЛ. ЧКАЛОВА, Д. 21
|
||||||
|
442530, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. КУЗНЕЦК, УЛ. БЕЛИНСКОГО, Д. 127/1
|
||||||
|
442895, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. СЕРДОБСК, УЛ. ЛЕНИНА, Д. 87
|
||||||
|
442963, ПЕНЗЕНСКАЯ ОБЛАСТЬ, Г. ЗАРЕЧНЫЙ, УЛ. ЛЕНИНА, Д. 20А
|
||||||
|
443001, РОССИЯ, САМАРСКАЯ ОБЛ., Г. САМАРА, ЛЕНИНСКИЙ Р-Н, УЛ. САДОВАЯ, Д. 256, ПОМ. Н 2
|
||||||
|
443009, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, ПРОМЫШЛЕННЫЙ РАЙОН, УЛ. ПОБЕДЫ, Д. 101/ УЛ. ВОРОНЕЖСКАЯ, Д. 3, КАБ. 1-12.
|
||||||
|
443013, САМАРСКАЯ ОБЛАСТЬ, Г.САМАРА, ОКТЯБРЬСКИЙ РАЙОН, УЛ. МИЧУРИНА, Д.19 В, 1 ЭТАЖ: ПОМЕЩЕНИЯ №27-№39
|
||||||
|
443041, Г.САМАРА, ЛЕНИНСКИЙ РАЙОН, УЛ.АГИБАЛОВА, Д.76
|
||||||
|
443045, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, ОКТЯБРЬСКИЙ РАЙОН, УЛ. ГАГАРИНА, Д. 64
|
||||||
|
443080, РОССИЯ, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, ШОССЕ МОСКОВСКОЕ, Д. 47
|
||||||
|
443087, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, ПРОСПЕКТ КИРОВА, Д. 228, УГОЛ УЛ. СТАРА-ЗАГОРА, Д. 126
|
||||||
|
443100, САМАРСКАЯ ОБЛАСТЬ, Г. САМАРА, УЛ. САМАРСКАЯ, Д. 220
|
||||||
|
445021, САМАРСКАЯ ОБЛАСТЬ, Г. ТОЛЬЯТТИ, УЛ. ЛЕНИНГРАДСКАЯ, ЗД. 68В
|
||||||
|
445026, РОССИЯ, САМАРСКАЯ ОБЛ., Г. ТОЛЬЯТТИ, АВТОЗАВОДСКИЙ Р-Н, УЛ. РЕВОЛЮЦИОННАЯ, Д. 32
|
||||||
|
445031, САМАРСКАЯ ОБЛАСТЬ, Г. ТОЛЬЯТТИ, УЛ. ТОПОЛИНАЯ, Д. 8А
|
||||||
|
445359, САМАРСКАЯ ОБЛАСТЬ, Г. ЖИГУЛЕВСК, МИКРОРАЙОН В-1, Д. 19
|
||||||
|
446001, РОССИЙСКАЯ ФЕДЕРАЦИЯ, САМАРСКАЯ ОБЛАСТЬ, Г. СЫЗРАНЬ, УЛ. СОВЕТСКАЯ, Д. 51
|
||||||
|
446031, САМАРСКАЯ ОБЛАСТЬ, Г. СЫЗРАНЬ, УЛ. 50 ЛЕТ ОКТЯБРЯ, Д. 54А
|
||||||
|
446200, САМАРСКАЯ ОБЛАСТЬ, Г. НОВОКУЙБЫШЕВСК, УЛ. МИРОНОВА, Д. 3
|
||||||
|
446300, САМАРСКАЯ ОБЛАСТЬ, Г. ОТРАДНЫЙ, УЛ. ОТРАДНАЯ, Д. 19, КВ. 21
|
||||||
|
446430, САМАРСКАЯ ОБЛАСТЬ, Г. КИНЕЛЬ, УЛ. МАЯКОВСКОГО, ЗД. 77/6
|
||||||
|
446450, САМАРСКАЯ ОБЛАСТЬ, Г. ПОХВИСТНЕВО, УЛ. РЕВОЛЮЦИОННАЯ, Д. 6
|
||||||
|
450008, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, УЛ. ЛЕНИНА, Д. 32/1, ПОМ. 1-5
|
||||||
|
450009, РЕСПУБЛИКА БАШКОРТОСТАН, Г.УФА, ПРОСПЕКТ ОКТЯБРЯ, Д.6
|
||||||
|
450039, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, УЛ. ТРАНСПОРТНАЯ, Д. 46
|
||||||
|
450044, РФ, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, УЛ. ПЕРВОМАЙСКАЯ, Д. 24
|
||||||
|
450075, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, ПР-КТ. ОКТЯБРЯ, Д. 107А
|
||||||
|
450099, РЕСПУБЛИКА БАШКОРТОСТАН, Г.УФА, УЛ. МАРШАЛА ЖУКОВА, Д.29.
|
||||||
|
450106, РОССИЙСКАЯ ФЕДЕРАЦИЯ, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УФА, УЛ. МЕНДЕЛЕЕВА, Д. 130
|
||||||
|
452005, РЕСПУБЛИКА БАШКОРТОСТАН, Г. БЕЛЕБЕЙ, УЛ. КРАСНАЯ, Д. 134
|
||||||
|
452320, РЕСПУБЛИКА БАШКОРТОСТАН, Г. ДЮРТЮЛИ, УЛ. ЛЕНИНА, Д. 36
|
||||||
|
452410, РЕСПУБЛИКА БАШКОРТОСТАН, С. ИГЛИНО, УЛ. ГОРЬКОГО, Д. 57
|
||||||
|
452450, РЕСПУБЛИКА БАШКОРТОСТАН, Г. БИРСК, УЛ. ИНТЕРНАЦИОНАЛЬНАЯ, Д. 23
|
||||||
|
452614, РЕСПУБЛИКА БАШКОРТОСТАН, Г. ОКТЯБРЬСКИЙ, ПРОСПЕКТ ЛЕНИНА, Д. 5
|
||||||
|
452680, РЕСПУБЛИКА БАШКОРТОСТАН, Г. НЕФТЕКАМСК, УЛИЦА ЛЕНИНА, Д. 19Е
|
||||||
|
452752, РЕСПУБЛИКА БАШКОРТОСТАН, Г. ТУЙМАЗЫ, ПР-КТ ЛЕНИНА, Д. 10
|
||||||
|
453120, РЕСПУБЛИКА БАШКОРТОСТАН, Г. СТЕРЛИТАМАК, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 96, ПОМ. 19
|
||||||
|
453260, РЕСПУБЛИКА БАШКОРТОСТАН, Г. САЛАВАТ, Б-Р КОСМОНАВТОВ, Д. 13
|
||||||
|
453300, РЕСПУБЛИКА БАШКОРТОСТАН, Г. КУМЕРТАУ, УЛ. КАРЛА МАРКСА, Д. 11, ПОМ. 10
|
||||||
|
453431, РЕСПУБЛИКА БАШКОРТОСТАН, Г. БЛАГОВЕЩЕНСК, УЛ. СЕДОВА, Д. 111, КВ. 22
|
||||||
|
453509, РЕСПУБЛИКА БАШКОРТОСТАН, Г. БЕЛОРЕЦК, УЛ. КАРЛА МАРКСА, Д. 75
|
||||||
|
453700, РЕСПУБЛИКА БАШКОРТОСТАН, Г. УЧАЛЫ, УЛ. ЛЕНИНА, Д. 19
|
||||||
|
453833, РЕСПУБЛИКА БАШКОРТОСТАН, Г. СИБАЙ, ПР-КТ ГОРНЯКОВ, Д. 22
|
||||||
|
453856, РЕСПУБЛИКА БАШКОРТОСТАН, Г. МЕЛЕУЗ, УЛ. ЛЕНИНА, Д. 133, ПОМ. 1
|
||||||
|
454091, Г. ЧЕЛЯБИНСК, УЛ. ЦВИЛЛИНГА, Д. 35
|
||||||
|
455000, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, ГОРОД МАГНИТОГОРСК, ПРОСПЕКТ ЛЕНИНА, ДОМ 51, ПОМ. 12
|
||||||
|
455045, ЧЕЛЯБИНСКАЯ ОБЛ., Г. МАГНИТОГОРСК, ПР. КАРЛА МАРКСА, Д. 179А
|
||||||
|
455045, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. МАГНИТОГОРСК, ПР. ЛЕНИНА, Д. 90
|
||||||
|
456014, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, АШИНСКИЙ РАЙОН, Г. АША, УЛ. ЛЕНИНА, Д. 19
|
||||||
|
456080, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. ТРЕХГОРНЫЙ, УЛ. МИРА, Д. 12, ПОМ. 55/2
|
||||||
|
456228, ЧЕЛЯБИНСКАЯ ОБЛ., Г. ЗЛАТОУСТ, ПР. ИМ. Ю.А. ГАГАРИНА, 3 М/Р-Н, Д. 14-В
|
||||||
|
456300, ЧЕЛЯБИНСКАЯ ОБЛ., Г. МИАСС, ПРОСП. АВТОЗАВОДЦЕВ, Д. 61 Б
|
||||||
|
456550, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. КОРКИНО, УЛ. ЦВИЛЛИНГА, Д. 8
|
||||||
|
456584, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. ЕМАНЖЕЛИНСК, УЛ. ТИТОВА, Д. 9
|
||||||
|
456618, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. КОПЕЙСК, ПРОСПЕКТ КОММУНИСТИЧЕСКИЙ, Д. 28
|
||||||
|
456770, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. СНЕЖИНСК, УЛ. СВЕРДЛОВА, Д. 36, ПОМ. 47
|
||||||
|
456800, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. ВЕРХНИЙ УФАЛЕЙ, УЛ. ЛЕНИНА, Д. 190, ПОМ. 8
|
||||||
|
456870, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. КЫШТЫМ, УЛ. ЛЕНИНА, Д. 6, ПОМ. 2
|
||||||
|
456880, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, АРГАЯШСКИЙ Р-Н, С. АРГАЯШ, УЛ. 8 МАРТА, Д. 36
|
||||||
|
456910, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Р-Н САТКИНСКИЙ, Г. САТКА, УЛ. ПРОЛЕТАРСКАЯ, Д. 41, ПОМ. 12
|
||||||
|
457100, ЧЕЛЯБИНСКАЯ ОБЛАСТЬ, Г. ТРОИЦК, УЛИЦА ИМ. Ю.А. ГАГАРИНА, Д. 41
|
||||||
|
460024, ОРЕНБУРГСКАЯ ОБЛ., Г. ОРЕНБУРГ, УЛ. МАРШАЛА ЖУКОВА, Д. 26
|
||||||
|
460052, РОССИЯ, ОРЕНБУРГСКАЯ ОБЛ., Г. ОРЕНБУРГ, УЛ. САЛМЫШСКАЯ, Д. 37
|
||||||
|
461040, РФ, ОРЕНБУРГСКАЯ ОБЛ., Г. БУЗУЛУК, УЛ. КОМСОМОЛЬСКАЯ, ЗД. № 81
|
||||||
|
461505, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. СОЛЬ-ИЛЕЦК, УЛ. СОВЕТСКАЯ, Д. 48, ЧАСТЬ ПОМ. №14
|
||||||
|
461630, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. БУГУРУСЛАН, УЛ. РЕВОЛЮЦИОННАЯ, ЗД. 46
|
||||||
|
461900, ОРЕНБУРГСКАЯ ОБЛАСТЬ, СОРОЧИНСКИЙ РАЙОН, Г. СОРОЧИНСК, УЛ. КАРЛА МАРКСА, ЗД. 13
|
||||||
|
462274, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. МЕДНОГОРСК, УЛ. ГЕРЦЕНА, Д. 1
|
||||||
|
462353, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. НОВОТРОИЦК, УЛ. СОВЕТСКАЯ, Д. 52, ПОМЕЩЕНИЕ 2
|
||||||
|
462422, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. ОРЕНБУРГ, УЛ. ЧКАЛОВА, Д. 35/1, СТРОЕН. 1
|
||||||
|
462431, ОРЕНБУРГСКАЯ ОБЛАСТЬ, Г. ОРСК, ПР-КТ ЛЕНИНА, Д. 78
|
||||||
|
600015, ВЛАДИМИРСКАЯ ОБЛ., Г. ВЛАДИМИР, ПР-Т ЛЕНИНА, Д. 26, 26А
|
||||||
|
600015, РФ, ВЛАДИМИРСКАЯ ОБЛ., Г. ВЛАДИМИР, ПР-Т ЛЕНИНА, Д. 26, 26А, ПОМ. 4
|
||||||
|
601010, ВЛАДИМИРСКАЯ ОБЛАСТЬ, КИРЖАЧСКИЙ РАЙОН, ГОРОД КИРЖАЧ, УЛИЦА ГАГАРИНА, ДОМ 29
|
||||||
|
601443, ВЛАДИМИРСКАЯ ОБЛАСТЬ, ВЯЗНИКОВСКИЙ Р-Н, Г. ВЯЗНИКИ, УЛ. ЛЕНИНА, Д. 12
|
||||||
|
601501, ВЛАДИМИРСКАЯ ОБЛАСТЬ, Г. ГУСЬ-ХРУСТАЛЬНЫЙ, УЛ. КАЛИНИНА, Д. 30, ЧАСТЬ ПОМ. № 12
|
||||||
|
601650, ВЛАДИМИРСКАЯ ОБЛАСТЬ, АЛЕКСАНДРОВСКИЙ Р-Н, Г. АЛЕКСАНДРОВ, УЛ. ЛЕНИНА, Д. 22, ПОМ. 9
|
||||||
|
601785, ВЛАДИМИРСКАЯ ОБЛАСТЬ, М.Р-Н КОЛЬЧУГИНСКИЙ, Г.П. ГОРОД КОЛЬЧУГИНО, Г. КОЛЬЧУГИНО, УЛ. 3 ИНТЕРНАЦИОНАЛА, Д. 65
|
||||||
|
601900, ВЛАДИМИРСКАЯ ОБЛАСТЬ, Г. КОВРОВ, ПРОСПЕКТ ЛЕНИНА, Д. 38
|
||||||
|
602267, ВЛАДИМИРСКАЯ ОБЛАСТЬ, Г. МУРОМ, УЛ. МОСКОВСКАЯ, Д. 47, ЧАСТЬ КОМНАТЫ №1 (48,83 КВ. М.)
|
||||||
|
603000, Г. НИЖНИЙ НОВГОРОД, НИЖЕГОРОДСКИЙ Р-Н, УЛ. НОВАЯ, Д. 8, ПОМ. П 37
|
||||||
|
603000, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, ВЕРХНЕ-ВОЛЖСКАЯ НАБЕРЕЖНАЯ, Д. 2А
|
||||||
|
603000, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, УЛ. КОСТИНА, Д. 9
|
||||||
|
603002, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, УЛ. ФИЛЬЧЕНКОВА, Д. 10
|
||||||
|
603003, НИЖЕГОРОДСКАЯ ОБЛ., Г. НИЖНИЙ НОВГОРОД, СОРМОВСКИЙ Р-Н, УЛ. КОМИНТЕРНА, Д. 164, ПОМ. П 5
|
||||||
|
603004, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НИЖЕГОРОДСКАЯ ОБЛ., Г. НИЖНИЙ НОВГОРОД, АВТОЗАВОДСКИЙ Р-Н, ПР-Т ЛЕНИНА, Д. 100 Д, ПОМ. П 8
|
||||||
|
603024, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, УЛ. БЕЛИНСКОГО, Д. 61, КОРП. 2 ЦОКОЛЬНЫЙ ЭТАЖ, ПОМЕЩЕНИЯ 1-10 (182,2 КВ. М)
|
||||||
|
603035, Г. НИЖНИЙ НОВГОРОД, УЛ. ГОРЬКОГО МАКСИМА, Д. 140, ПОМ. П1
|
||||||
|
603135, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ НОВГОРОД, ЛЕНИНСКИЙ РАЙОН, ПРОСП. ЛЕНИНА, Д. 45
|
||||||
|
606029, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. ДЗЕРЖИНСК, УЛИЦА ГАЙДАРА, Д. 61
|
||||||
|
606100, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. ПАВЛОВО, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 33, СТР. 1
|
||||||
|
606408, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. БАЛАХНА, УЛ. КОММУНИСТИЧЕСКАЯ, Д. 2, ПОМ. П17
|
||||||
|
606440, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. БОР, УЛ. ЛЕНИНА, Д. 129
|
||||||
|
606502, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. ГОРОДЕЦ, УЛ. КОЛХОЗНАЯ, Д. 1
|
||||||
|
606524, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, М.О. ГОРОДЕЦКИЙ, Г. ЗАВОЛЖЬЕ, УЛ. ПОНОМАРЕВА, Д. 4
|
||||||
|
607018, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. КУЛЕБАКИ, УЛ. ВОРОВСКОГО, Д. 47
|
||||||
|
607061, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. ВЫКСА, УЛ. ОСТРОВСКОГО, ЗДАНИЕ № 33
|
||||||
|
607183, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. САРОВ, УЛ. МОСКОВСКАЯ, Д. 3, СТР. 1
|
||||||
|
607228, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, Г. АРЗАМАС, ПРОСПЕКТ ЛЕНИНА, Д. 198, ПОМ. 1
|
||||||
|
607600, НИЖЕГОРОДСКАЯ ОБЛАСТЬ, М.О. БОГОРОДСКИЙ, Г. БОГОРОДСК, УЛ. ЛЕНИНА, Д. 189
|
||||||
|
607651, НИЖЕГОРОДСКАЯ ОБЛ., Г. КСТОВО, УЛ. 40 ЛЕТ ОКТЯБРЯ, Д. 4, ПОМ. III (69,1 КВ.М.)
|
||||||
|
610027, КИРОВСКАЯ ОБЛАСТЬ, Г. КИРОВ, УЛ. ВЛАДИМИРСКАЯ, Д. 99А
|
||||||
|
610045, КИРОВСКАЯ ОБЛАСТЬ, Г. КИРОВ, УЛ. МАРШАЛА И.С. КОНЕВА, Д. 1
|
||||||
|
612964, КИРОВСКАЯ ОБЛАСТЬ, Г. ВЯТСКИЕ ПОЛЯНЫ, УЛ. ГАГАРИНА, Д. 9Г
|
||||||
|
613050, КИРОВСКАЯ ОБЛАСТЬ, Г. КИРОВО-ЧЕПЕЦК, ПРОСПЕКТ МИРА, Д. 74
|
||||||
|
613150, КИРОВСКАЯ ОБЛАСТЬ, Г. СЛОБОДСКОЙ, УЛ. СОВЕТСКАЯ, Д. 66, ПОМ. 1017
|
||||||
|
614000, ПЕРМСКИЙ КРАЙ, Г.ПЕРМЬ, ЛЕНИНСКИЙ Р-Н, УЛ.СОВЕТСКАЯ, Д.45
|
||||||
|
614000, ПЕРМСКИЙ КРАЙ, ГОРОД ПЕРМЬ, УЛ. ЛЕНИНА, ДОМ 66
|
||||||
|
614015, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПЕРМСКИЙ КРАЙ, Г. ПЕРМЬ, УЛ. ПЕРМСКАЯ, Д. 17
|
||||||
|
614039, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПЕРМСКИЙ КРАЙ, Г. ПЕРМЬ, СВЕРДЛОВСКИЙ Р-Н, КОМСОМОЛЬСКИЙ ПРОСП., Д. 49
|
||||||
|
614066, ПЕРМСКИЙ КРАЙ, ГОРОД ПЕРМЬ, УЛИЦА МИРА, ДОМ 70
|
||||||
|
614077, ПЕРМСКИЙ КРАЙ, Г. ПЕРМЬ, БУЛЬВАР ГАГАРИНА, Д. 67
|
||||||
|
614113, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПЕРМСКИЙ КРАЙ, Г. ПЕРМЬ, УЛ. АВТОЗАВОДСКАЯ, Д. 44А
|
||||||
|
617060, ПЕРМСКИЙ КРАЙ, Г. КРАСНОКАМСК, ПРОСПЕКТ МИРА, Д. 6
|
||||||
|
617470, ПЕРМСКИЙ КРАЙ, Г. КУНГУР, УЛ. ПУГАЧЕВА, Д. 25
|
||||||
|
618419, ПЕРМСКИЙ КРАЙ, ГОРОД БЕРЕЗНИКИ, УЛИЦА ПЯТИЛЕТКИ, ДОМ 50, МАГАЗИН №29
|
||||||
|
618554, ПЕРМСКИЙ КРАЙ, Г. СОЛИКАМСК, УЛ. 20-ЛЕТИЯ ПОБЕДЫ, Д. 119
|
||||||
|
618740, ПЕРМСКИЙ КРАЙ, Г.О. ДОБРЯНСКИЙ, Г. ДОБРЯНКА, УЛ. ПОБЕДЫ, Д. 43
|
||||||
|
618900, ПЕРМСКИЙ КРАЙ, Г. ЛЫСЬВА, УЛ. МИРА, Д. 11
|
||||||
|
619000, ПЕРМСКИЙ КРАЙ, Г. КУДЫМКАР, УЛ. КАЛИНИНА, Д. 37
|
||||||
|
620000, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. ГОРЬКОГО, СТР. 7-А
|
||||||
|
620000, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. ГОРЬКОГО, СТР. 7-А, ПОМ. 75-78
|
||||||
|
620000, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. ГОРЬКОГО, СТР. 7-А, ПОМ. 81-86
|
||||||
|
620012, Г. ЕКАТЕРИНБУРГ, УЛ. КУЗНЕЦОВА, Д.2 ПОМЕЩЕНИЯ: КОМНАТЫ №№1-7, ЧАСТЬ КОМНАТЫ №8 (ПЛОЩАДЬЮ 60,8 КВ.М.), КОМНАТА №9, КОМНАТЫ №№20-28
|
||||||
|
620012, СВЕРД. ОБЛ., Г.ЕКАТЕРИНБУРГ, УЛ.МАШИНОСТРОИТЕЛЕЙ, Д.19 ПОМ:НА 1ЭТ: К.197, ЧАСТЬ К.198 (185,97КВ.М.), 200,203-205,247-255; НА ЦОК.ЭТ: К.60,60А-60Е,62-64,64А,64Б,91,91А,91Б, ЧАСТЬ К.92(54КВ.М.)
|
||||||
|
620014, Г.ЕКАТЕРИНБУРГ, УЛ.8 МАРТА, Д.10/УЛ.МАЛЫШЕВА, Д.42 (ЛИТЕР А)
|
||||||
|
620014, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. 8 МАРТА, Д. 10/ УЛ. МАЛЫШЕВА, Д. 42 (ЛИТЕР А), ПОМ. 229
|
||||||
|
620026, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. СОНИ МОРОЗОВОЙ, Д.190. ПОМ: ЛИТА: К.3,8; ЧАСТЬ К.9 (3,5КВ.М.), К.10-11;13-14,16-18;21; ВСТР ЛИТА: К.25,26;28-32;34, ЧАСТЬ К.36 (206,3 КВ.М.)
|
||||||
|
620041, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. УРАЛЬСКАЯ, Д. 75, ПОМ. 1-21
|
||||||
|
620062, Г. ЕКАТЕРИНБУРГ, ПР-Т ЛЕНИНА, Д. 60
|
||||||
|
620073, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. ЕКАТЕРИНБУРГ, УЛ. РОДОНИТОВАЯ, Д. 4
|
||||||
|
620102, Г. ЕКАТЕРИНБУРГ, УЛ. БЕЛОРЕЧЕНСКАЯ, Д. 12А. 1 ЭТАЖ-ПОМЕЩЕНИЯ №№7-9, 2 ЭТАЖ-ПОМЕЩЕНИЯ №№1-2, №№4-5, ЧАСТЬ ПОМЕЩЕНИЯ №6 (169,1 КВ.М.), №№7-8
|
||||||
|
620109, РОССИЙСКАЯ ФЕДЕРАЦИЯ, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. КРАУЛЯ, Д. 44, ЧАСТЬ ПОМЕЩЕНИЯ № 74 (33,85 КВ. М.)
|
||||||
|
620144, Г. ЕКАТЕРИНБУРГ, УЛ. 8 МАРТА, Д. 194
|
||||||
|
620149, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЕКАТЕРИНБУРГ, УЛ. ВИЛЬГЕЛЬМА ДЕ ГЕННИНА, Д. 31 (1 ЭТАЖ - ПОМЕЩЕНИЯ 34-39)
|
||||||
|
622007,СВЕРДЛОВСКАЯ ОБЛАСТЬ,Г.НИЖНИЙ ТАГИЛ,ПР-Т ВАГОНОСТРОИТЕЛЕЙ, Д.13,/УЛ.БАЖОВА, Д.6. ПОДВАЛ: К. № 1-№10. 1 ЭТАЖ: КОМНАТЫ №9,№14,№15,ЧАСТЬ КОМНАТЫ №16(3,6 КВ.М.),№17-№21,№23-№26,№29,№31,№33, №35-№38
|
||||||
|
622034, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. НИЖНИЙ ТАГИЛ, ПР-КТ ЛЕНИНА, Д. 61, ПОМ. № 1-000, 1-001, 1-003-1-013
|
||||||
|
623104, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ПЕРВОУРАЛЬСК, УЛ. ВАТУТИНА, Д. 37
|
||||||
|
623286, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. РЕВДА, УЛ. МАКСИМА ГОРЬКОГО, Д. 30, ЧАСТЬ ПОМ. 51
|
||||||
|
623300, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. КРАСНОУФИМСК, УЛ. СОВЕТСКАЯ, Д. 51
|
||||||
|
623382, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ПОЛЕВСКОЙ, М-ОН ЗЕЛЕНЫЙ БОР-1, ЗД. 4А
|
||||||
|
623400, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г.КАМЕНСК-УРАЛЬСКИЙ, ПР.ПОБЕДЫ, Д.39 А
|
||||||
|
623530, СВЕРДЛОВСКАЯ ОБЛАСТЬ, БОГДАНОВИЧСКИЙ РАЙОН, ГОРОД БОГДАНОВИЧ, УЛИЦА СПОРТИВНАЯ, ДОМ 2
|
||||||
|
623700, СВЕРДЛОВСКАЯ ОБЛАСТЬ, ГОРОД БЕРЕЗОВСКИЙ, УЛИЦА ВОСТОЧНАЯ, ДОМ 11А
|
||||||
|
623750, СВЕРДЛОВСКАЯ ОБЛ., РЕЖЕВСКИЙ Р-Н, Г. РЕЖ, УЛ. ЛЕНИНА, СТР. 5
|
||||||
|
623785, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. АРТЕМОВСКИЙ, УЛ. ЛЕНИНА, ЗД. 2В, ЧАСТЬ ПОМ. 14
|
||||||
|
623850, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ИРБИТ, УЛ. КАЛИНИНА, Д. 19А, ПОМ. 9
|
||||||
|
624022, СВЕРДЛОВСКАЯ ОБЛАСТЬ, СЫСЕРТСКИЙ Р-Н, Г. СЫСЕРТЬ, УЛ. КОММУНЫ, Д. 41
|
||||||
|
624090, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ВЕРХНЯЯ ПЫШМА, ПР-Т УСПЕНСКИЙ, Д. 20, ОФ. 2.5.3
|
||||||
|
624132, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. НОВОУРАЛЬСК, УЛ. КОМСОМОЛЬСКАЯ, ЗДАНИЕ 6А
|
||||||
|
624205, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЛЕСНОЙ, УЛ. ЛЕНИНА, Д. 64
|
||||||
|
624250, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ЗАРЕЧНЫЙ, УЛ. ЛЕНИНГРАДСКАЯ, Д. 16, ПОМ. 147
|
||||||
|
624272, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. АСБЕСТ, ПРОСПЕКТ ИМЕНИ В.И. ЛЕНИНА, Д. 15, ПОМ. 1-002
|
||||||
|
624350, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. КАЧКАНАР, УЛ. СВЕРДЛОВА, ДОМ 10, ПОМ. 1-003
|
||||||
|
624440, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. КРАСНОТУРЬИНСК, УЛ. ЛЕНИНА, Д. 37
|
||||||
|
624600, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. АЛАПАЕВСК, УЛ. ЛЕНИНА, Д. 12, ЧАСТЬ ПОМ. №№30-33
|
||||||
|
624760, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. ВЕРХНЯЯ САЛДА, УЛ. ЭНГЕЛЬСА, Д. 77, ПОМ. 1-002
|
||||||
|
624800, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. СУХОЙ ЛОГ, УЛ. ГОРЬКОГО, Д. 14
|
||||||
|
624992, СВЕРДЛОВСКАЯ ОБЛАСТЬ, Г. СЕРОВ, УЛ. ЛЕНИНА, Д. 161
|
||||||
|
625000, Г. ТЮМЕНЬ, УЛИЦА 8 МАРТА, ДОМ 2/13.
|
||||||
|
625000, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. ТЮМЕНЬ, УЛ. ЛЕНИНА, ДОМ 57/2, ПОМ. № 1, 2, 3, 8, 10, 11, 12
|
||||||
|
625000, ТЮМЕНСКАЯ ОБЛ., Г. ТЮМЕНЬ, УЛ. СОВЕТСКАЯ, Д. 55/3
|
||||||
|
625002, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТЮМЕНЬ, УЛ. ОСИПЕНКО, Д. 21, КОРП. 1
|
||||||
|
625022, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТЮМЕНЬ, УЛ. ГАЗОВИКОВ, Д. 65/4
|
||||||
|
625026, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТЮМЕНЬ, УЛ. РЕСПУБЛИКИ, Д. 155
|
||||||
|
625048, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ТЮМЕНСКАЯ ОБЛАСТЬ, ГОРОДСКОЙ ОКРУГ ТЮМЕНЬ, Г. ТЮМЕНЬ, УЛ. 50 ЛЕТ ОКТЯБРЯ, Д. 3/1, ЛИТЕРА А
|
||||||
|
625051, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТЮМЕНЬ, УЛ. ПЕРМЯКОВА, Д. 23 А
|
||||||
|
626158, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ТОБОЛЬСК, 9-Й МИКРОРАЙОН, Д. 1
|
||||||
|
627140, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ЗАВОДОУКОВСК, УЛ. ВОКЗАЛЬНАЯ, Д. 42
|
||||||
|
627754, ТЮМЕНСКАЯ ОБЛАСТЬ, Г. ИШИМ, УЛ. КАРЛА МАРКСА, Д. 77А
|
||||||
|
628011, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, ГОРОД ХАНТЫ-МАНСИЙСК, УЛИЦА ЭНГЕЛЬСА, ДОМ 3, ПОМ. 2
|
||||||
|
628181, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. НЯГАНЬ, МКР. 1-Й, Д. 1, ПОМ. 170
|
||||||
|
628240, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. СОВЕТСКИЙ, УЛ. ГАСТЕЛЛО, ЗД. 28А
|
||||||
|
628285, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. УРАЙ, МКР. 2-Й, Д. 88, ПОМ. 51
|
||||||
|
628306, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. НЕФТЕЮГАНСК, 15 МИКРОРАЙОН, 8Б
|
||||||
|
628380, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. ПЫТЬ-ЯХ, МКР. 1-Й ЦЕНТРАЛЬНЫЙ, Д. 17, КВ. 3
|
||||||
|
628406, ТЮМЕНСКАЯ ОБЛАСТЬ, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. СУРГУТ, УЛ. ИОСИФА КАРОЛИНСКОГО, 13
|
||||||
|
628416, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. СУРГУТ, ПРОСП. ЛЕНИНА Д. 38
|
||||||
|
628449, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. ЛЯНТОР, УЛ. НАЗАРГАЛЕЕВА, СТР. 6А
|
||||||
|
628462, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. РАДУЖНЫЙ, МКР. 6, Д. 24
|
||||||
|
628484, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. КОГАЛЫМ, УЛ. ЛЕНИНГРАДСКАЯ, Д. 17
|
||||||
|
628600, ТЮМЕНСКАЯ ОБЛАСТЬ, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. НИЖНЕВАРТОВСК, ПРОСПЕКТ ПОБЕДЫ, Д. 11
|
||||||
|
628611, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. НИЖНЕВАРТОВСК, УЛ. ЛЕНИНА, Д. 17А
|
||||||
|
628671, ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ - ЮГРА, Г. ЛАНГЕПАС, УЛ. СОЛНЕЧНАЯ, Д. 20
|
||||||
|
628681, ТЮМЕНСКАЯ ОБЛ., ХАНТЫ-МАНСИЙСКИЙ АВТОНОМНЫЙ ОКРУГ, Г. МЕГИОН, УЛ. НЕФТЕРАЗВЕДОЧНАЯ, Д. 2
|
||||||
|
629008, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. САЛЕХАРД, УЛ. МИРА, Д. 2, КВ. 5
|
||||||
|
629303, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. НОВЫЙ УРЕНГОЙ, МКР. МИРНЫЙ, Д. 6, КОРП. 2
|
||||||
|
629400, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. ЛАБЫТНАНГИ, УЛ. ОКТЯБРЬСКАЯ, Д. 21, ПОМ. 5
|
||||||
|
629735, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. НАДЫМ, УЛ. ЗВЕРЕВА, Д. 37А
|
||||||
|
629807, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. НОЯБРЬСК, ПР-КТ МИРА, Д. 56
|
||||||
|
629832, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. ГУБКИНСКИЙ, 12 МИКРОРАЙОН, Д. 43
|
||||||
|
629850, ЯМАЛО-НЕНЕЦКИЙ АВТОНОМНЫЙ ОКРУГ, Г. ТАРКО-САЛЕ, УЛ. РЕСПУБЛИКИ, Д. 45
|
||||||
|
630004, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. ЛЕНИНА, Д. 52, ПОМ. 12
|
||||||
|
630004, РФ, Г. НОВОСИБИРСК, ЖЕЛЕЗНОДОРОЖНЫЙ Р-Н, УЛ. ЛЕНИНА, Д. 52, ПОМ. 1-9 (147 КВ.М)
|
||||||
|
630054, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. ТИТОВА, ЗД. 22А
|
||||||
|
630073, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ГОРОД НОВОСИБИРСК, ЛЕНИНСКИЙ РАЙОН, ПРОСПЕКТ КАРЛА МАРКСА, ДОМ 55. ПОМЕЩЕНИЯ: №№ 1-6; ЧАСТЬ ПОМЕЩЕНИЯ №7 (ПЛОЩАДЬЮ 5,45 КВ.М.), № 8, № 9
|
||||||
|
630078, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НОВОСИБИРСКАЯ ОБЛ., Г. НОВОСИБИРСК, ПЛ. КАРЛА МАРКСА, Д. 5
|
||||||
|
630082, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛИЦА ДУСИ КОВАЛЬЧУК, 179/4, 1 ЭТАЖ, КАБИНЕТЫ С №1 ПО №13
|
||||||
|
630089, РФ, Г. НОВОСИБИРСК, УЛ. БОРИСА БОГАТКОВА, Д. 201/1
|
||||||
|
630090, НОВОСИБИРСКАЯ ОБЛ., Г. НОВОСИБИРСК, УЛ. ИЛЬИЧА, Д. 6, ПОМЕЩЕНИЕ 5
|
||||||
|
630091, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. НОВОСИБИРСК, УЛ. МИЧУРИНА, ДОМ 23
|
||||||
|
630091, РОССИЙСКАЯ ФЕДЕРАЦИЯ, НОВОСИБИРСКАЯ ОБЛ., Г. НОВОСИБИРСК, УЛ. КРЫЛОВА, Д. 31
|
||||||
|
630099, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. СОВЕТСКАЯ, ЗД. 33
|
||||||
|
630099, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. СОВЕТСКАЯ, ЗД. 33, ПОМ. 1
|
||||||
|
630102, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. НОВОСИБИРСК, УЛ. КИРОВА, Д. 46/1, 1 ЭТАЖ, ЧАСТЬ ПОМЕЩЕНИЯ №13 (236,25 КВ. М), ПОМЕЩЕНИЕ №14 (1,5 КВ. М)
|
||||||
|
630559, НОВОСИБИРСКАЯ ОБЛАСТЬ, РП. КОЛЬЦОВО, ПР-КТ НИКОЛЬСКИЙ, Д. 2, ПОМ. 12
|
||||||
|
632122, НОВОСИБИРСКАЯ ОБЛАСТЬ, ТАТАРСКИЙ Р-Н, Г. ТАТАРСК, УЛ. ЛЕНИНА, ЗД. 106Г
|
||||||
|
632382, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. КУЙБЫШЕВ, 11 КВАРТАЛ, Д. 9
|
||||||
|
632640, НОВОСИБИРСКАЯ ОБЛАСТЬ, РП. КОЧЕНЕВО, УЛ. МАКСИМА ГОРЬКОГО, Д. 142
|
||||||
|
632865, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. КАРАСУК, УЛ. ЛЕНИНА, ЗД. 120В
|
||||||
|
633010, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. БЕРДСК, УЛ. ЛЕНИНА, 41А
|
||||||
|
633103, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. ОБЬ, УЛ. ЛОМОНОСОВА, ЗД. 7
|
||||||
|
633162, НОВОСИБИРСКАЯ ОБЛАСТЬ, КОЛЫВАНСКИЙ Р-Н, Р.П. КОЛЫВАНЬ, УЛ. МОСКОВСКАЯ, Д. 37В
|
||||||
|
633209, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. ИСКИТИМ, УЛ. ПУШКИНА, Д. 40А
|
||||||
|
633216, РФ, НОВОСИБИРСКАЯ ОБЛ., ИСКИТИМСКИЙ Р-Н, Р.П. ЛИНЕВО, КОММУНИСТИЧЕСКИЙ ПРОСП., Д. 1 А, ПОМЕЩЕНИЕ № 2, 1 ЭТАЖ
|
||||||
|
633456, НОВОСИБИРСКАЯ ОБЛАСТЬ, Г. ТОГУЧИН, УЛ. КОМСОМОЛЬСКАЯ, ЗД. 20/1, ЧАСТЬ ПОМ. №114
|
||||||
|
634009, ТОМСКАЯ ОБЛАСТЬ, Г. ТОМСК, ПЕР. 1905 ГОДА, Д. 14А, ПОМ. 1007-1018
|
||||||
|
634034, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ТОМСКАЯ ОБЛ., Г. ТОМСК, УЛ. КРАСНОАРМЕЙСКАЯ, Д. 101 А, ПОМ. 1186
|
||||||
|
634049, ТОМСКАЯ ОБЛАСТЬ, Г. ТОМСК, ТРАКТ ИРКУТСКИЙ, Д. 26
|
||||||
|
634061, ТОМСКАЯ ОБЛАСТЬ, Г. ТОМСК, ПР. КОМСОМОЛЬСКИЙ, Д. 53
|
||||||
|
636000, ТОМСКАЯ ОБЛАСТЬ, Г. СЕВЕРСК, ПР-КТ КОММУНИСТИЧЕСКИЙ, 63, ПОМЕЩ. 1001
|
||||||
|
636460, ТОМСКАЯ ОБЛАСТЬ, Г. КОЛПАШЕВО, УЛ. БЕЛИНСКОГО, Д. 16/1, СТР. 1
|
||||||
|
636785, ТОМСКАЯ ОБЛАСТЬ, Г. СТРЕЖЕВОЙ, МКР. 4-Й, Д. 417, ПОМЕЩ. 31
|
||||||
|
636840, ТОМСКАЯ ОБЛАСТЬ, Г. АСИНО, УЛ. ИМЕНИ ЛЕНИНА, Д. 31
|
||||||
|
640000, РОССИЯ, КУРГАНСКАЯ ОБЛАСТЬ, Г. КУРГАН, УЛ. ГОГОЛЯ, Д. 17/1, ПОМ. № 1, № 2, № 10, № 12
|
||||||
|
640002, КУРГАНСКАЯ ОБЛАСТЬ, Г. КУРГАН, УЛ. ГОГОЛЯ, Д. 39Б
|
||||||
|
644010, ОМСКАЯ ОБЛАСТЬ, Г. ОМСК, ПРОСПЕКТ МАРКСА, Д. 10 (ПОМЕЩЕНИЕ 4П)
|
||||||
|
644020, ОМСКАЯ ОБЛАСТЬ, Г. ОМСК, ПР-Т КАРЛА МАРКСА, Д. 84
|
||||||
|
644021, Г ОМСК, УЛ. БОГДАНА ХМЕЛЬНИЦКОГО, Д.162. ПОМЕЩЕНИЕ №18, ЧАСТЬ ПОМЕЩЕНИЯ №19 ПЛОЩАДЬЮ 37,99КВ.М., ПОМЕЩЕНИЯ №№20-25
|
||||||
|
644024, РФ, ОМСКАЯ ОБЛ., Г. ОМСК, УЛ. МАРШАЛА ЖУКОВА УГОЛ УЛ. 10 ЛЕТ ОКТЯБРЯ, Д. 25/31, ПОМ. 101-113 (293,1 КВ. М)
|
||||||
|
644029, РФ, ОМСКАЯ ОБЛ., Г. ОМСК, ПР-Т МИРА, Д. 54, ЛИТЕРА: А, ЭТАЖ-1
|
||||||
|
644043, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. ОМСК, УЛ. КРАСНЫЙ ПУТЬ, ДОМ №61. ЧАСТЬ ПОМЕЩЕНИЯ №5, ПЛОЩАДЬЮ 29 КВ.М., ПОМЕЩЕНИЕ №14; ЧАСТЬ ПОМЕЩЕНИЯ №15, ПЛОЩАДЬЮ 4.6 КВ.М; ПОМЕЩЕНИЕ №№16-22.
|
||||||
|
644074, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. ОМСК, УЛ. 70 ЛЕТ ОКТЯБРЯ, ДОМ 13, КОРПУС № 3. ПОМЕЩЕНИЯ №№1-6, ЧАСТЬ ПОМЕЩЕНИЯ №7 ПЛОЩАДЬЮ 3 КВ.М.
|
||||||
|
646024, ОМСКАЯ ОБЛАСТЬ, ИСИЛЬКУЛЬСКИЙ РАЙОН, ГОРОД ИСИЛЬКУЛЬ, УЛИЦА КОММУНИСТИЧЕСКАЯ, ДОМ 12
|
||||||
|
646530, ОМСКАЯ ОБЛАСТЬ, Г. ТАРА, УЛ. ЛЕНИНА, Д. 77
|
||||||
|
646901, ОМСКАЯ ОБЛАСТЬ, Г. КАЛАЧИНСК, УЛ. ПЕТРА ИЛЬИЧЕВА, Д. 17
|
||||||
|
649000, РЕСПУБЛИКА АЛТАЙ, Г. ГОРНО-АЛТАЙСК, ПР. КОММУНИСТИЧЕСКИЙ, Д. 27
|
||||||
|
650000, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КЕМЕРОВСКАЯ ОБЛАСТЬ, КЕМЕРОВСКИЙ ГОРОДСКОЙ ОКРУГ, ГОРОД КЕМЕРОВО, ПР-КТ СОВЕТСКИЙ, ДОМ 32, ПОМЕЩЕНИЕ 13
|
||||||
|
650003, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КЕМЕРОВСКАЯ ОБЛАСТЬ, Г.КЕМЕРОВО, ЛЕНИНСКИЙ РАЙОН, БР.СТРОИТЕЛЕЙ, ДОМ № 28, ЛИТЕР А
|
||||||
|
650066, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. КЕМЕРОВО, ПР-КТ ЛЕНИНА, Д. 64А
|
||||||
|
652420, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. БЕРЕЗОВСКИЙ, ПРОСПЕКТ ЛЕНИНА, Д. 14, ПОМ. 1А
|
||||||
|
652470, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. АНЖЕРО-СУДЖЕНСК, УЛ. ЛЕНИНА, Д. 22
|
||||||
|
652500, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. ЛЕНИНСК-КУЗНЕЦКИЙ, ПРОСПЕКТ КИРОВА, Д. 49
|
||||||
|
652600, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. БЕЛОВО, УЛ. ЮБИЛЕЙНАЯ, Д. 8
|
||||||
|
652700, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. КИСЕЛЕВСК, УЛ. ЛЕНИНА, Д. 38
|
||||||
|
652815, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. ОСИННИКИ, УЛ. ЕФИМОВА, Д. 9
|
||||||
|
652870, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. МЕЖДУРЕЧЕНСК, КОММУНИСТИЧЕСКИЙ ПР-КТ, Д. 18
|
||||||
|
653039, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. ПРОКОПЬЕВСК, УЛ. ШИШКИНА, Д. 11, ПОМ. 3П, ПОМ. 5
|
||||||
|
654007, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. НОВОКУЗНЕЦК, ПР-КТ Н. С. ЕРМАКОВА, ЗД. 9А
|
||||||
|
654007, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, Г. НОВОКУЗНЕЦК, ПРОСПЕКТ МЕТАЛЛУРГОВ, 32
|
||||||
|
654080, КЕМЕРОВСКАЯ ОБЛАСТЬ - КУЗБАСС, ГОРОДСКОЙ ОКРУГ НОВОКУЗНЕЦКИЙ, ГОРОД НОВОКУЗНЕЦК, РАЙОН ЦЕНТРАЛЬНЫЙ, УЛИЦА ТОЛЬЯТТИ, ДОМ 44, ПОМ. 47
|
||||||
|
655017, РЕСПУБЛИКА ХАКАСИЯ, Г. АБАКАН, УЛ. ЩЕТИНКИНА, Д. 63
|
||||||
|
655602, РЕСПУБЛИКА ХАКАСИЯ, Г. САЯНОГОРСК, МКР. ЛЕНИНГРАДСКИЙ, Д. 2
|
||||||
|
656011, АЛТАЙСКИЙ КРАЙ, Г. БАРНАУЛ, ЖЕЛЕЗНОДОРОЖНЫЙ Р-Н, ПР-КТ ЛЕНИНА, Д. 106, ПОМ.305
|
||||||
|
656011, РОССИЯ, АЛТАЙСКИЙ КР., Г.БАРНАУЛ, ПР-КТ ЛЕНИНА, Д.106
|
||||||
|
656015, АЛТАЙСКИЙ КРАЙ, Г. БАРНАУЛ, СОЦИАЛИСТИЧЕСКИЙ ПРОСПЕКТ, Д. 130. ЧАСТЬ ПОМЕЩЕНИЯ №1 (91,83 КВ.М.), ПОМЕЩЕНИЯ №№ 2-17
|
||||||
|
656038, АЛТАЙСКИЙ КРАЙ, Г.БАРНАУЛ, ЛЕНИНА ПРОСПЕКТ, Д. 58. ПОМЕЩЕНИЯ: №1, ЧАСТЬ ПОМЕЩЕНИЯ №2 (97,39 КВ.М.), №№ 3-9
|
||||||
|
656049, АЛТАЙСКИЙ КРАЙ, Г. БАРНАУЛ, КРАСНОАРМЕЙСКИЙ ПРОСПЕКТ, ДОМ 58 "Б", 1 ЭТАЖ, ПОМЕЩЕНИЯ №№3,4, ЧАСТЬ ПОМЕЩЕНИЯ №5 (187,86 КВ.М.)
|
||||||
|
656054, РОССИЙСКАЯ ФЕДЕРАЦИЯ, Г. БАРНАУЛ, УЛ. АНТОНА ПЕТРОВА, Д. 219 Б
|
||||||
|
656057, АЛТАЙСКИЙ КРАЙ, Г. БАРНАУЛ, ТРАКТ ПАВЛОВСКИЙ, Д. 251 А
|
||||||
|
658087, АЛТАЙСКИЙ КРАЙ, Г. НОВОАЛТАЙСК, УЛ. ОКТЯБРЬСКАЯ, Д. 13
|
||||||
|
658210, АЛТАЙСКИЙ КРАЙ, Г. РУБЦОВСК, ПР-КТ ЛЕНИНА, Д. 46
|
||||||
|
658700, АЛТАЙСКИЙ КРАЙ, Г. КАМЕНЬ-НА-ОБИ, УЛ. ПУШКИНА, Д. 25
|
||||||
|
658828, АЛТАЙСКИЙ КРАЙ, Г. СЛАВГОРОД, МКР 3-Й, Д. 15
|
||||||
|
659100, АЛТАЙСКИЙ КРАЙ, Г. ЗАРИНСК, ПР-КТ СТРОИТЕЛЕЙ, ЗДАНИЕ 22
|
||||||
|
659305, АЛТАЙСКИЙ КРАЙ, Г. БИЙСК, УЛ. ИМЕНИ ГЕРОЯ СОВЕТСКОГО СОЮЗА ВАСИЛЬЕВА, Д. 26, ПОМ. Н3
|
||||||
|
660001, РФ, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛ. КОПЫЛОВА, Д. 66, ПОМ. 331
|
||||||
|
660017, Г. КРАСНОЯРСК, УЛ. ГОРЬКОГО, Д. 10
|
||||||
|
660017, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛ. ГОРЬКОГО, Д. 10, ЧАТЬ ПОМ. №98
|
||||||
|
660037, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КРАСНОЯРСКИЙ КРАЙ, Г.КРАСНОЯРСК, ПРОСПЕКТ ИМЕНИ ГАЗЕТЫ "КРАСНОЯРСКИЙ РАБОЧИЙ", ДОМ 35, ПОМ 56,57
|
||||||
|
660041, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛ. ВЫСОТНАЯ, Д. 1, ПОМ. 66
|
||||||
|
660049, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, ПРОСП. МИРА, Д. 10, ПОМ. 106, КОМ. № 1- № 14
|
||||||
|
660049, РОССИЯ, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛ. ДУБРОВИНСКОГО, Д. 52 А, ПОМ. 77
|
||||||
|
660093, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, ПРОСП. ИМ. ГАЗЕТЫ "КРАСНОЯРСКИЙ РАБОЧИЙ", Д. 199, ПОМ. 215, ПОМЕЩЕНИЯ: № 1, ЧАСТЬ ПОМЕЩЕНИЯ № 2, №№3-11
|
||||||
|
660135, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КРАСНОЯРСКИЙ КРАЙ, Г. КРАСНОЯРСК, УЛИЦА ВЗЛЕТНАЯ 18, ПОМ.154
|
||||||
|
662153, КРАСНОЯРСКИЙ КРАЙ, Г. АЧИНСК, КВАРТАЛ 28, Д. 5, ПОМ. 30
|
||||||
|
662200, КРАСНОЯРСКИЙ КРАЙ, Г. НАЗАРОВО, УЛ. АРБУЗОВА, Д. 110
|
||||||
|
662501, КРАСНОЯРСКИЙ КРАЙ, Г. СОСНОВОБОРСК, УЛ. ЛЕНИНСКОГО КОМСОМОЛА, Д. 28, ПОМ. 155
|
||||||
|
662547, КРАСНОЯРСКИЙ КРАЙ, ГОРОД ЛЕСОСИБИРСК, МИКРОРАЙОН 5-Й, ДОМ 26
|
||||||
|
662606, КРАСНОЯРСКИЙ КРАЙ, Г. МИНУСИНСК, УЛ. АБАКАНСКАЯ, Д. 48
|
||||||
|
662971, РОССИЙСКАЯ ФЕДЕРАЦИЯ, КРАСНОЯРСКИЙ КРАЙ, ЗАТО ЖЕЛЕЗНОГОРСК, Г. ЖЕЛЕЗНОГОРСК, УЛ. СВЕРДЛОВА, ДОМ 21, ПОМ.36
|
||||||
|
663091, КРАСНОЯРСКИЙ КРАЙ, Г. ДИВНОГОРСК, УЛ. 30 ЛЕТ ПОБЕДЫ, Д. 16
|
||||||
|
663604, КРАСНОЯРСКИЙ КРАЙ, Г. КАНСК, УЛ. 40 ЛЕТ ОКТЯБРЯ, ЗД. 62, СТР. 4, ПОМ. 15
|
||||||
|
663693, КРАСНОЯРСКИЙ КРАЙ, Г. ЗЕЛЕНОГОРСК, УЛ. ЛЕНИНА, Д. 2
|
||||||
|
664003, Г. ИРКУТСК, УЛ. ЛЕНИНА, Д. 25
|
||||||
|
664003, РФ, ИРКУТСКАЯ ОБЛ., Г. ИРКУТСК, УЛ. ЛИТВИНОВА, Д. 2. ПОМЕЩЕНИЯ: 1А, 2-6, 6А, 1 ЭТАЖ
|
||||||
|
664022, ИРКУТСКАЯ ОБЛАСТЬ, Г. ИРКУТСК, УЛ. КОЖОВА, 20, ПОМ. 101-110
|
||||||
|
664074, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ИРКУТСКАЯ ОБЛАСТЬ, Г. ИРКУТСК, УЛ. ЛЕРМОНТОВА, Д.63
|
||||||
|
665008, ИРКУТСКАЯ ОБЛАСТЬ, Г. ТАЙШЕТ, УЛ. ГАГАРИНА, Д. 125А
|
||||||
|
665103, ИРКУТСКАЯ ОБЛАСТЬ, Г. НИЖНЕУДИНСК, УЛ. МАСЛОВСКОГО, Д. 28-1
|
||||||
|
665268, ИРКУТСКАЯ ОБЛАСТЬ, Г. ТУЛУН, УЛ. ЛЕНИНА, Д. 89
|
||||||
|
665462, ИРКУТСКАЯ ОБЛАСТЬ, Г. УСОЛЬЕ-СИБИРСКОЕ, УЛ. ОРДЖОНИКИДЗЕ, ЗД. 40
|
||||||
|
665708, ИРКУТСКАЯ ОБЛАСТЬ, Г. БРАТСК, УЛ. КИРОВА, Д. 18
|
||||||
|
665824, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ИРКУТСКАЯ ОБЛАСТЬ, Г. АНГАРСК, КВАРТАЛ 212, Д. 15, ПОМ. 1-5
|
||||||
|
666301, ИРКУТСКАЯ ОБЛАСТЬ, Г. САЯНСК, МИКРОРАЙОН ЮБИЛЕЙНЫЙ, Д. 16
|
||||||
|
666683, ИРКУТСКАЯ ОБЛАСТЬ, Г. УСТЬ-ИЛИМСК, ПР-КТ МИРА, Д. 2
|
||||||
|
666784, ИРКУТСКАЯ ОБЛАСТЬ, Г. УСТЬ-КУТ, УЛ. КИРОВА, Д. 88
|
||||||
|
667000, РЕСПУБЛИКА ТЫВА, Г. КЫЗЫЛ, УЛ. ЛЕНИНА, Д. 24
|
||||||
|
670000, РЕСПУБЛИКА БУРЯТИЯ, Г. УЛАН-УДЭ, УЛ. ЛЕНИНА, Д. 35
|
||||||
|
670031, РЕСПУБЛИКА БУРЯТИЯ, Г. УЛАН-УДЭ, УЛ. ТЕРЕШКОВОЙ, Д. 2
|
||||||
|
671700, РЕСПУБЛИКА БУРЯТИЯ, Г. СЕВЕРОБАЙКАЛЬСК, ПР-КТ ЛЕНИНГРАДСКИЙ, Д. 8
|
||||||
|
674600, ЗАБАЙКАЛЬСКИЙ КРАЙ, Г. БОРЗЯ, УЛ. СОВЕТСКАЯ, ЗД. 21
|
||||||
|
674676, ЗАБАЙКАЛЬСКИЙ КРАЙ, Г. КРАСНОКАМЕНСК, ПР-КТ ШАХТЕРОВ, Д. 4А
|
||||||
|
675004, АМУРСКАЯ ОБЛАСТЬ, Г. БЛАГОВЕЩЕНСК, УЛ. ОСТРОВСКОГО, Д. 12, ЧАСТЬ ПОМ. 11 (167,4 КВ.М.), ПОМ. 34, ПОМ. 35
|
||||||
|
676244, АМУРСКАЯ ОБЛАСТЬ, Г. ЗЕЯ, УЛ. ШОХИНА, Д. 4
|
||||||
|
676282, АМУРСКАЯ ОБЛАСТЬ, Г. ТЫНДА, УЛ. МОСКОВСКИХ СТРОИТЕЛЕЙ, СТРОЕН. 2
|
||||||
|
676454, АМУРСКАЯ ОБЛАСТЬ, Г. СВОБОДНЫЙ, УЛ. ЛЕНИНА, Д. 47
|
||||||
|
676805, АМУРСКАЯ ОБЛАСТЬ, Г. БЕЛОГОРСК, УЛ. КИРОВА, Д. 119
|
||||||
|
678174, РЕСПУБЛИКА САХА (ЯКУТИЯ), Г. МИРНЫЙ, ПР-КТ ЛЕНИНГРАДСКИЙ, Д. 25, ПОМ. 3
|
||||||
|
678901, РЕСПУБЛИКА САХА (ЯКУТИЯ), Г. АЛДАН, УЛ. ОКТЯБРЬСКАЯ, Д. 25
|
||||||
|
678967, РЕСПУБЛИКА САХА (ЯКУТИЯ), Г. НЕРЮНГРИ, ПР-КТ ЛЕНИНА, Д. 6, ПОМ. 80 - 82
|
||||||
|
679016, ЕВРЕЙСКАЯ АВТОНОМНАЯ ОБЛАСТЬ, Г. БИРОБИДЖАН, УЛ. ШОЛОМ-АЛЕЙХЕМА, ЗД. 8
|
||||||
|
680000, ХАБАРОВСКИЙ КРАЙ, Г.ХАБАРОВСК, УЛ. МУРАВЬЕВА-АМУРСКОГО, Д. 36
|
||||||
|
680000, ХАБАРОВСКИЙ КРАЙ, Г.ХАБАРОВСК, УЛ.МУРАВЬЕВА-АМУРСКОГО, Д. 36
|
||||||
|
680017, ХАБАРОВСКИЙ КРАЙ, Г. ХАБАРОВСК, УЛ. ЛЕНИНА, Д. 51, ПОМ. I
|
||||||
|
680051, ХАБАРОВСКИЙ КРАЙ, Г. ХАБАРОВСК, УЛ. СУВОРОВА, Д. 32, ПОМ. I (61-66, 68, 114-118)
|
||||||
|
681013, ХАБАРОВСКИЙ КРАЙ, Г. КОМСОМОЛЬСК-НА-АМУРЕ, ПРОСПЕКТ ЛЕНИНА, Д. 13, ПОМ. 1002 (ПОМ. №2, ПОМ. №8, ЧАСТЬ ПОМ. №6)
|
||||||
|
682644, ХАБАРОВСКИЙ КРАЙ, Г. АМУРСК, ПР-КТ КОМСОМОЛЬСКИЙ, Д. 2
|
||||||
|
683024, КАМЧАТСКИЙ КРАЙ, Г. ПЕТРОПАВЛОВСК-КАМЧАТСКИЙ, ПР-КТ 50 ЛЕТ ОКТЯБРЯ, Д. 24А
|
||||||
|
684000, КАМЧАТСКИЙ КРАЙ, Г. ЕЛИЗОВО, УЛ. ЗАВОЙКО, Д. 7
|
||||||
|
690002, ПРИМОРСКИЙ КРАЙ, Г. ВЛАДИВОСТОК, ПР-Т ОСТРЯКОВА, Д. 5Г
|
||||||
|
690021, ПРИМОРСКИЙ КРАЙ, Г. ВЛАДИВОСТОК, УЛ. ХАРЬКОВСКАЯ, Д. 8
|
||||||
|
690048, Г. ВЛАДИВОСТОК, ПРОСПЕКТ 100 ЛЕТ ВЛАДИВОСТОКУ, Д.51
|
||||||
|
690091, ПРИМОРСКИЙ КРАЙ, Г. ВЛАДИВОСТОК, УЛ. СВЕТЛАНСКАЯ, Д. 43
|
||||||
|
690091, РОССИЙСКАЯ ФЕДЕРАЦИЯ, ПРИМОРСКИЙ КРАЙ, Г. ВЛАДИВОСТОК, УЛ. АВРОРОВСКАЯ, Д. 10
|
||||||
|
692042, ПРИМОРСКИЙ КРАЙ, Г. ЛЕСОЗАВОДСК, УЛ. КАЛИНИНСКАЯ, Д. 11
|
||||||
|
692245, ПРИМОРСКИЙ КРАЙ, Г. СПАССК-ДАЛЬНИЙ, УЛ. БОРИСОВА, Д. 23
|
||||||
|
692342, ПРИМОРСКИЙ КРАЙ, Г. АРСЕНЬЕВ, УЛ. КАЛИНИНСКАЯ, Д. 24
|
||||||
|
692441, ПРИМОРСКИЙ КРАЙ, Г. ДАЛЬНЕГОРСК, ПРОСПЕКТ 50 ЛЕТ ОКТЯБРЯ, Д. 52, ПОМЕЩЕНИЕ 201
|
||||||
|
692525, ПРИМОРСКИЙ КРАЙ, Г. УССУРИЙСК, УЛ. ПУШКИНА, Д. 27
|
||||||
|
692760, ПРИМОРСКИЙ КРАЙ, Г. АРТЕМ, УЛ. ФРУНЗЕ, Д. 65, ПОМ. XIX
|
||||||
|
692806, ПРИМОРСКИЙ КРАЙ, Г. БОЛЬШОЙ КАМЕНЬ, УЛ. КАРЛА МАРКСА, Д. 13
|
||||||
|
692864, ПРИМОРСКИЙ КРАЙ, Г. ПАРТИЗАНСК, УЛ. ЛЕНИНСКАЯ, Д. 5, ПОМ. IV
|
||||||
|
692914, ПРИМОРСКИЙ КРАЙ, Г. НАХОДКА, ПРОСПЕКТ СЕВЕРНЫЙ, Д. 2, КОРПУС 1
|
||||||
|
694020, САХАЛИНСКАЯ ОБЛАСТЬ, Г. КОРСАКОВ, УЛ. СОВЕТСКАЯ, Д. 31
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
pdguard:
|
||||||
|
systems-file: src/test/resources/systems-test.json
|
||||||
|
warmup-iterations: 0
|
||||||
|
ner:
|
||||||
|
engine: off
|
||||||
|
store:
|
||||||
|
backend: memory
|
||||||
|
|
||||||
|
spring:
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,metrics,prometheus
|
||||||
|
prometheus:
|
||||||
|
metrics:
|
||||||
|
export:
|
||||||
|
enabled: true
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Набор под доработки: словарь топонимов, реальные адреса отделений Альфа-Банка
|
||||||
|
# (собраны через поиск, сентябрь 2026 — banki.ru/2ГИС/сравни.ру агрегируют их из
|
||||||
|
# открытых источников банка), расширенный денилист, обобщённое companion-правило
|
||||||
|
# и новые банковские типы (счёт, БИК, срок карты, ОГРН(ИП), КПП, доход, биометрия).
|
||||||
|
#
|
||||||
|
# Блок ниже — 60 адресов из официального реестра ЦБ РФ (cbr.ru/finorg/foinfo/branches,
|
||||||
|
# раздел «Информация о подразделениях АО АЛЬФА-БАНК», сентябрь 2026), по одному на
|
||||||
|
# регион, где город прошёл проверку по словарю топонимов. Полный список (981 адрес,
|
||||||
|
# 509 городов) — в alfa-bank-offices-full.txt. Смысл не в объёме (вето одно и то же),
|
||||||
|
# а в разнообразии: официальные формулировки типов улиц (просп./пр-кт/б-р/тракт/шоссе)
|
||||||
|
# и городов по всей стране, а не только Москва.
|
||||||
|
|
||||||
|
# --- Реальные адреса отделений Альфа-Банка: ловушка из ТЗ, не ПД клиента ---
|
||||||
|
Ближайшее отделение Альфа-Банка: г. Москва, ул. Каланчёвская, 27
|
||||||
|
Дополнительный офис Альфа-Банка на Тверской улице, 28 к1, работает до 20:00
|
||||||
|
Отделение на Кузнецком Мосту, 9/10 закрыто на переучёт
|
||||||
|
Офис Альфа-Банка на Ленинградском проспекте, 74к1 принимает по записи
|
||||||
|
Филиал на Новослободской улице, 23 переехал в соседнее здание
|
||||||
|
Дополнительный офис по адресу Большая Грузинская, 57 стр. 1 временно не работает
|
||||||
|
Отделение на Пресненском Валу, 7 стр. 1 обслуживает юрлиц
|
||||||
|
Офис на проспекте Мира, 91 корп. 2 открылся в новом формате
|
||||||
|
Отделение на Неглинной улице, 10 работает по будням
|
||||||
|
Юридический адрес: г. Москва, ул. Каланчёвская, 27, Альфа-Банк
|
||||||
|
|
||||||
|
Отделение Альфа-Банка: Новосибирская Область, Г. Карасук, Ул. Ленина, Зд. 120в
|
||||||
|
Отделение Альфа-Банка: Г Омск, Ул. Богдана Хмельницкого, Д.162. Помещение №18, Часть Помещения №19 Площадью 37,99кв.м., Помещения №№20-25
|
||||||
|
Отделение Альфа-Банка: Смоленская Область, Г. Смоленск, Ул. Николаева, Д. 8, Пом. 3
|
||||||
|
Отделение Альфа-Банка: Псковская Область, Г. Великие Луки, Проспект Ленина, Д. 39
|
||||||
|
Отделение Альфа-Банка: Г. Санкт-петербург, Комендантский Проспект, Д. 13, Корп. 1, Литера А, Помещение 1-н
|
||||||
|
Отделение Альфа-Банка: Г. Нижний Новгород, Нижегородский Р-н, Ул. Новая, Д. 8, Пом. П 37
|
||||||
|
Отделение Альфа-Банка: Ульяновская Область, Г. Барыш, Ул. 45 Стрелковой Дивизии, Зд.1г
|
||||||
|
Отделение Альфа-Банка: Краснодарский Край, Г. Гулькевичи, Ул. Ленинградская, Д. 3
|
||||||
|
Отделение Альфа-Банка: Г. Саратов, Пр-т Строителей, Д. 19
|
||||||
|
Отделение Альфа-Банка: Г. Иркутск, Ул. Ленина, Д. 25
|
||||||
|
Отделение Альфа-Банка: Орловская Область, Г. Мценск, Ул. Ленина, Д. 25
|
||||||
|
Отделение Альфа-Банка: Еврейская Автономная Область, Г. Биробиджан, Ул. Шолом-алейхема, Зд. 8
|
||||||
|
Отделение Альфа-Банка: Владимирская Область, Г. Ковров, Проспект Ленина, Д. 38
|
||||||
|
Отделение Альфа-Банка: Свердловская Обл., Режевский Р-н, Г. Реж, Ул. Ленина, Стр. 5
|
||||||
|
Отделение Альфа-Банка: Г. Волгоград Ул. Комсомольская, Д.10
|
||||||
|
Отделение Альфа-Банка: Сахалинская Область, Г. Корсаков, Ул. Советская, Д. 31
|
||||||
|
Отделение Альфа-Банка: Ставропольский Край, Г. Ессентуки, Привокзальная Площадь, Пом. 1к
|
||||||
|
Отделение Альфа-Банка: Костромская Область, Г. Кострома, Ул. Советская, Д. 30
|
||||||
|
Отделение Альфа-Банка: Воронежская Область, Г. Богучар, Пл Малаховского, Д 12/1
|
||||||
|
Отделение Альфа-Банка: Г. Воронеж, Ул. Кирова, Д. 11
|
||||||
|
Отделение Альфа-Банка: Г.самара, Ленинский Район, Ул.агибалова, Д.76
|
||||||
|
Отделение Альфа-Банка: Республика Бурятия, Г. Северобайкальск, Пр-кт Ленинградский, Д. 8
|
||||||
|
Отделение Альфа-Банка: Рязанская Обл., Г. Касимов, Ул. Советская, Д. 11
|
||||||
|
Отделение Альфа-Банка: Республика Башкортостан, Г. Бирск, Ул. Интернациональная, Д. 23
|
||||||
|
Отделение Альфа-Банка: Г. Москва, Пер. Денежный, Д. 3, Строен. 1
|
||||||
|
Отделение Альфа-Банка: Г. Санкт-петербург Лиговский Пр., 73, Литер А
|
||||||
|
Отделение Альфа-Банка: Амурская Область, Г. Тында, Ул. Московских Строителей, Строен. 2
|
||||||
|
Отделение Альфа-Банка: Красноярский Край, Г. Назарово, Ул. Арбузова, Д. 110
|
||||||
|
Отделение Альфа-Банка: Ростовская Обл., Г. Новошахтинск, Пр-кт Ленина, Д. 5-а
|
||||||
|
Отделение Альфа-Банка: Рязанская Область, Г. Скопин, Ул. К.маркса, Д. 178
|
||||||
|
Отделение Альфа-Банка: Г. Ростов-на-дону, Пр. Космонавтов, 15
|
||||||
|
Отделение Альфа-Банка: Г. Краснодар, Прикубанский Округ, Ул. Им. Тургенева, Дом 107
|
||||||
|
Отделение Альфа-Банка: Саратовская Область, Г. Маркс, Ул. Октябрьская, Д. 53
|
||||||
|
Отделение Альфа-Банка: Г. Мурманск, Пр. Ленина, Д. 19а
|
||||||
|
Отделение Альфа-Банка: Чеченская Республика, Г. Грозный, Ул. Им С.ш. Лорсанова, Д. 14
|
||||||
|
Отделение Альфа-Банка: Хабаровский Край, Г. Комсомольск-на-амуре, Проспект Ленина, Д. 13, Пом. 1002 (пом. №2, Пом. №8, Часть Пом. №6)
|
||||||
|
Отделение Альфа-Банка: Г. Липецк, Ул. Барашева, Д. 7. Помещения Подвала: №№ 10-11; Первого Этажа: №№ 6-7.
|
||||||
|
Отделение Альфа-Банка: Томская Область, Г. Томск, Пер. 1905 Года, Д. 14а, Пом. 1007-1018
|
||||||
|
Отделение Альфа-Банка: Приморский Край, Г. Уссурийск, Ул. Пушкина, Д. 27
|
||||||
|
Отделение Альфа-Банка: Брянская Область, Г. Клинцы, Пр-т Ленина, Д. 21
|
||||||
|
Отделение Альфа-Банка: Ямало-ненецкий Автономный Округ, Г. Тарко-сале, Ул. Республики, Д. 45
|
||||||
|
Отделение Альфа-Банка: Республика Марий Эл, Г. Волжск, Ул. Ленина, Д. 56б
|
||||||
|
Отделение Альфа-Банка: Красноярский Край, Зато Железногорск, Г. Железногорск, Ул. Свердлова, Дом 21, Пом.36
|
||||||
|
Отделение Альфа-Банка: Самарская Область, Г. Сызрань, Ул. 50 Лет Октября, Д. 54а
|
||||||
|
Отделение Альфа-Банка: Республика Тыва, Г. Кызыл, Ул. Ленина, Д. 24
|
||||||
|
Отделение Альфа-Банка: Республика Алтай, Г. Горно-алтайск, Пр. Коммунистический, Д. 27
|
||||||
|
Отделение Альфа-Банка: Республика Северная Осетия-алания, Г. Владикавказ, Ул. Генерала Плиева, Д. 10
|
||||||
|
Отделение Альфа-Банка: Забайкальский Край, Г. Борзя, Ул. Советская, Зд. 21
|
||||||
|
Отделение Альфа-Банка: Г. Екатеринбург, Ул. 8 Марта, Д. 194
|
||||||
|
Отделение Альфа-Банка: Алтайский Край, Г. Барнаул, Железнодорожный Р-н, Пр-кт Ленина, Д. 106, Пом.305
|
||||||
|
Отделение Альфа-Банка: Ульяновская Обл., Г. Ульяновск, Ул. Герасимова, Д. 10, Стр. 4, Пом. 106
|
||||||
|
Отделение Альфа-Банка: Г. Челябинск, Ул. Цвиллинга, Д. 35
|
||||||
|
Отделение Альфа-Банка: Московская Область, Г. Одинцово, Тер. Мкад, Км 63-й, Стр. 1
|
||||||
|
Отделение Альфа-Банка: Владимирская Обл., Г. Владимир, Пр-т Ленина, Д. 26, 26а
|
||||||
|
Отделение Альфа-Банка: Пензенская Область, Г. Сердобск, Ул. Ленина, Д. 87
|
||||||
|
Отделение Альфа-Банка: Г. Владивосток, Проспект 100 Лет Владивостоку, Д.51
|
||||||
|
Отделение Альфа-Банка: Тюменская Обл., Ханты-мансийский Автономный Округ, Г. Мегион, Ул. Нефтеразведочная, Д. 2
|
||||||
|
Отделение Альфа-Банка: Калужская Обл., Г. Людиново, Ул. Ленина, Д. 4
|
||||||
|
Отделение Альфа-Банка: Омская Область, Г. Тара, Ул. Ленина, Д. 77
|
||||||
|
Отделение Альфа-Банка: Г. Архангельск, Ул. Воскресенская, Д. 6
|
||||||
|
|
||||||
|
# --- Денилист: расширенный список публичных фигур ---
|
||||||
|
Набиуллина заявила о повышении ключевой ставки
|
||||||
|
Греф прокомментировал результаты квартала
|
||||||
|
Интервью с Костиным о стратегии банка
|
||||||
|
Мишустин подписал постановление правительства
|
||||||
|
Дуров опроверг слухи о продаже компании
|
||||||
|
Потанин инвестировал в новый проект
|
||||||
|
|
||||||
|
# --- Обобщённое companion-правило: место рождения и страна ---
|
||||||
|
Родился в Нижнем Новгороде в прошлом веке
|
||||||
|
Место рождения: Владивосток, известный портовый город
|
||||||
|
Цены на нефть выросли в Казахстане в этом квартале
|
||||||
|
Страна изучает опыт Узбекистана в цифровизации
|
||||||
|
{{FIO:Смирнов Артём Викторович}}, место рождения {{BIRTH_PLACE:Новосибирск}}
|
||||||
|
{{FIO:Волкова Дарья Игоревна}}, страна проживания {{ADDRESS_COUNTRY:Армения}}
|
||||||
|
|
||||||
|
# --- Банковские реквизиты рядом с данными человека: маскируются ---
|
||||||
|
Клиент {{FIO:Волков Игорь Петрович}}, расчётный счёт {{ACCOUNT_NUMBER:40702810500000001234}}
|
||||||
|
Перевод {{FIO:Волковой Дарье}}, БИК {{BIK:044525593}} банка-получателя
|
||||||
|
Карта {{CARD:4111 1111 1111 1111}}, срок действия {{CARD_EXPIRY:09/27}}
|
||||||
|
Директор {{FIO:Волков И.П.}}, ОГРН {{OGRN:1027700132195}} организации
|
||||||
|
ИП {{FIO:Волков Игорь Петрович}}, ОГРНИП {{OGRNIP:304500116000157}}
|
||||||
|
Заявитель {{FIO:Волкова Д.И.}}, КПП {{KPP:770101001}} в реквизитах счёта
|
||||||
|
|
||||||
|
# --- Те же реквизиты сами по себе: человека в строке нет, маскировать нечего ---
|
||||||
|
# В перечне типов из задания счёта, БИК, ОГРН, ОГРНИП и КПП нет: сами по себе они
|
||||||
|
# опознают организацию или счёт. Поэтому они переведены в requireCompanion — как
|
||||||
|
# пин-код, дата и место рождения.
|
||||||
|
Расчётный счёт 40702810500000001234 закрыт по заявлению
|
||||||
|
БИК 044525593 указан в платёжном поручении
|
||||||
|
ОГРН 1027700132195 организации проверен
|
||||||
|
ОГРНИП 304500116000157 индивидуального предпринимателя
|
||||||
|
КПП 770101001 в реквизитах счёта
|
||||||
|
Доход клиента {{INCOME:95 000}} руб. подтверждён справкой 2-НДФЛ
|
||||||
|
Заработная плата {{INCOME:150000}} перечисляется на карту
|
||||||
|
Клиент прошёл верификацию через {{BIOMETRIC:биометрические данные}}
|
||||||
|
Оформлен {{BIOMETRIC:слепок голоса}} для системы {{BIOMETRIC:ЕБС}}
|
||||||
|
|
||||||
|
# --- Города в разных падежах: проверка словаря топонимов ---
|
||||||
|
Клиент {{FIO:Игорь Соловьёв}} зарегистрирован в г. {{ADDRESS_CITY:Тюмени}}
|
||||||
|
Обращение поступило из г. {{ADDRESS_CITY:Владивостока}}
|
||||||
|
Перевод отправлен в г. {{ADDRESS_CITY:Мытищи}}
|
||||||
|
Курьер доставит документы в г. {{ADDRESS_CITY:Зеленоград}}
|
||||||
|
|
||||||
|
# --- Обычные тексты без ПД ---
|
||||||
|
Как рассчитать эффективную процентную ставку по кредиту
|
||||||
|
Подготовь отчёт по динамике курса валют за квартал
|
||||||
|
Расскажи про требования к пакету документов для ИП
|
||||||
|
Какие льготы положены при досрочном погашении ипотеки
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# СГЕНЕРИРОВАННЫЙ НЕЗАВИСИМЫЙ НАБОР. Правила по нему не настраивались — составлен
|
||||||
|
# после того, как код уже был написан, специально для проверки. Имена, номера
|
||||||
|
# документов и формулировки не пересекаются с benchmark.txt, benchmark-holdout.txt
|
||||||
|
# и benchmark-holdout2.txt. Номера карт/ИНН/СНИЛС подобраны так, чтобы проходить
|
||||||
|
# контрольную сумму — иначе строка проверяла бы не детектор, а качество случайных
|
||||||
|
# цифр. Покрывает все типы ПД из ТЗ, вариации написания дат и серии/номера,
|
||||||
|
# регистронезависимость, companion-правило (CVV/PIN/DATE без других ПД рядом)
|
||||||
|
# и ложные срабатывания (адрес отделения банка, известные исторические фигуры).
|
||||||
|
|
||||||
|
# --- ФИО: порядок слов, инициалы, ролевые слова ---
|
||||||
|
Заявление подал {{FIO:Смирнов Артём Викторович}}
|
||||||
|
{{FIO:Виктория Романовна Кузнецова}} оформила вклад на три месяца
|
||||||
|
Документы подписал {{FIO:Соколов А.П.}}
|
||||||
|
С уважением, {{FIO:Т.Н. Морозова}}
|
||||||
|
Клиент {{FIO:Дмитрий Волков}} закрыл счёт досрочно
|
||||||
|
Заёмщик {{FIO:Орлова}} внесла платёж по графику
|
||||||
|
Созаёмщик {{FIO:Николай Беляев}} подтвердил согласие
|
||||||
|
Поручитель {{FIO:Егоров}} не ответил на звонок
|
||||||
|
ФИО: {{FIO:Григорьев Максим Олегович}}
|
||||||
|
Ф.И.О. {{FIO:Лебедева Полина Сергеевна}}
|
||||||
|
Обращение принял оператор, на имя {{FIO:Захарова}} оформлена заявка
|
||||||
|
Комиссия в составе {{FIO:Козлов В.С.}}, {{FIO:Морозова Т.Н.}}, {{FIO:Беляев Н.А.}}
|
||||||
|
{{FIO:ИВАНОВ ИВАН ИВАНОВИЧ}} обратился повторно
|
||||||
|
{{FIO:Марк Соловьёв}} и {{FIO:Анна Григорьева}} подали заявку вместе
|
||||||
|
Клиент {{FIO:иванова мария петровна}} обратилась повторно
|
||||||
|
|
||||||
|
# --- Ложные срабатывания: известные фигуры, адрес отделения, обычные слова ---
|
||||||
|
Собрание сочинений Толстого стоит на верхней полке
|
||||||
|
Портрет Пушкина висит в кабинете литературы
|
||||||
|
Мы изучаем сонеты Шекспира на этой неделе
|
||||||
|
Иван Грозный правил в шестнадцатом веке
|
||||||
|
Третий Рим — известная историческая концепция
|
||||||
|
Ближайшее отделение банка находится по адресу: г. Казань, ул. Баумана, д. 7
|
||||||
|
Юридический адрес банка: г. Москва, ул. Ильинка, д. 9
|
||||||
|
Реквизиты головного офиса опубликованы на сайте банка
|
||||||
|
Расскажи, как оформить кредитную карту онлайн
|
||||||
|
Какая процентная ставка по вкладу на полгода
|
||||||
|
Составь напоминание про оплату коммунальных услуг
|
||||||
|
|
||||||
|
# --- Даты: числом в разном порядке, словом, с разными якорями ---
|
||||||
|
Дата рождения {{BIRTH_DATE:15.03.1990}}
|
||||||
|
Дата рождения {{BIRTH_DATE:1990-03-15}}
|
||||||
|
Родился {{BIRTH_DATE:05.11.1988}} в {{BIRTH_PLACE:Новосибирске}}
|
||||||
|
{{BIRTH_DATE:22.07.1979}} г.р.
|
||||||
|
Дата рождения {{BIRTH_DATE:двенадцатого мая тысяча девятьсот восемьдесят пятого года}}
|
||||||
|
Дата выдачи {{PASSPORT_DATE:12.05.2015}}
|
||||||
|
Дата выдачи паспорта {{PASSPORT_DATE:03.09.2018}}
|
||||||
|
|
||||||
|
# --- Паспорт, серия/номер разными способами, орган выдачи, код подразделения ---
|
||||||
|
Паспорт гражданина РФ {{PASSPORT:4510 654321}}
|
||||||
|
Паспорт {{PASSPORT:4511987654}}
|
||||||
|
серия {{PASSPORT:4512}} номер {{PASSPORT:112233}}
|
||||||
|
серии {{PASSPORT:45 13}} за номером {{PASSPORT:445566}}
|
||||||
|
Код подразделения {{DEPT_CODE:770-123}}
|
||||||
|
к/п {{DEPT_CODE:500011}}
|
||||||
|
выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}} 12.05.2015
|
||||||
|
выдан {{PASSPORT_ISSUER:Отделом УФМС по Республике Татарстан}}, действителен
|
||||||
|
|
||||||
|
# --- Документы, удостоверяющие личность, кроме паспорта РФ ---
|
||||||
|
Водительское удостоверение {{DRIVER_LICENSE:5014 778899}}
|
||||||
|
в/у {{DRIVER_LICENSE:5015112233}}
|
||||||
|
Загранпаспорт {{FOREIGN_PASSPORT:71 2233445}}
|
||||||
|
Военный билет {{MILITARY_ID:АБ 3344556}}
|
||||||
|
Свидетельство о рождении {{BIRTH_CERTIFICATE:II-АБ №778899}}
|
||||||
|
Полис ОМС {{MEDICAL_POLICY:1122334455667788}}
|
||||||
|
|
||||||
|
# --- Гражданство, место рождения ---
|
||||||
|
Гражданство {{CITIZENSHIP:РФ}}
|
||||||
|
Гражданин {{CITIZENSHIP:России}}
|
||||||
|
Гражданство {{CITIZENSHIP:Республики Беларусь}}
|
||||||
|
Место рождения {{BIRTH_PLACE:город Тверь}}
|
||||||
|
|
||||||
|
# --- Адрес: составляющие по отдельности и вместе ---
|
||||||
|
Индекс {{ADDRESS_POSTCODE:614000}}
|
||||||
|
Проживает по адресу: г. {{ADDRESS_CITY:Пермь}}, ул. {{ADDRESS_STREET:Ленина}}, д. {{ADDRESS_HOUSE:14}}, кв. {{ADDRESS_FLAT:56}}
|
||||||
|
Зарегистрирован по адресу: {{ADDRESS_POSTCODE:220015}}, г. {{ADDRESS_CITY:Минск}}, ул. {{ADDRESS_STREET:Немига}}, д. {{ADDRESS_HOUSE:3}}
|
||||||
|
Страна проживания {{ADDRESS_COUNTRY:Казахстан}}
|
||||||
|
Курьер привезёт документы на Малую Бронную улицу к вечеру
|
||||||
|
|
||||||
|
# --- Email, телефон ---
|
||||||
|
Свяжитесь по адресу {{EMAIL:artem.smirnov90@example.com}}
|
||||||
|
Напишите на {{EMAIL:v.kuznetsova@mail.example}}
|
||||||
|
Телефон {{PHONE:+7 903 445-67-12}}
|
||||||
|
Позвоните на {{PHONE:8 (912) 334-56-78}}
|
||||||
|
Мобильный: {{PHONE:+79261234567}}
|
||||||
|
|
||||||
|
# --- ИНН, СНИЛС, карта: контрольная сумма ---
|
||||||
|
ИНН {{INN:7707083893}}
|
||||||
|
ИНН физического лица {{INN:501001233116}}
|
||||||
|
СНИЛС {{SNILS:112-233-445-95}}
|
||||||
|
Номер карты {{CARD:4276 1234 5678 1235}}
|
||||||
|
Оплата картой {{CARD:4276123456781235}} прошла успешно
|
||||||
|
Держатель карты {{CARDHOLDER:IVAN PETROV}}
|
||||||
|
на имя {{CARDHOLDER:ANNA VOLKOVA}}
|
||||||
|
|
||||||
|
# --- CVV/PIN: companion-правило (одни, без карты рядом, не маскируются) ---
|
||||||
|
CVV 482
|
||||||
|
Код проверки 917
|
||||||
|
PIN 4821
|
||||||
|
Пин-код 773311
|
||||||
|
Оплата картой {{CARD:4276123456781235}}, CVV {{CVV:482}}, держатель {{CARDHOLDER:IVAN PETROV}}
|
||||||
|
Оплата картой {{CARD:4276123456781235}}, пин-код {{PIN:4821}} не подошёл
|
||||||
|
|
||||||
|
# --- Дата без якоря: companion-правило ---
|
||||||
|
Встреча назначена на 14.02.2026
|
||||||
|
Клиент {{FIO:Соколов А.П.}} заходил {{DATE:14.02.2026}}
|
||||||
|
|
||||||
|
# --- Сложные предложения: несколько типов ПД в одной строке ---
|
||||||
|
Клиент {{FIO:Гаврилов Степан Ильич}}, паспорт {{PASSPORT:4520 998877}}, тел. {{PHONE:+7 985 221-33-44}}, e-mail {{EMAIL:s.gavrilov@example.com}}
|
||||||
|
{{FIO:Кузнецова Виктория Романовна}}, {{BIRTH_DATE:03.04.1992}} г.р., зарегистрирована по адресу: г. {{ADDRESS_CITY:Тверь}}, ул. {{ADDRESS_STREET:Советская}}, д. {{ADDRESS_HOUSE:21}}, кв. {{ADDRESS_FLAT:9}}
|
||||||
|
Заёмщик {{FIO:Петров Виктор Николаевич}}, гражданство {{CITIZENSHIP:РФ}}, ИНН {{INN:7707083893}}, СНИЛС {{SNILS:112-233-445-95}}
|
||||||
|
|
||||||
|
# --- Обычные банковские вопросы без ПД ---
|
||||||
|
Как заблокировать карту при утере
|
||||||
|
Чем отличается ипотека от потребительского кредита
|
||||||
|
Подготовь черновик ответа на жалобу клиента
|
||||||
|
Объясни, что такое кредитный рейтинг
|
||||||
|
Сколько действует справка о доходах
|
||||||
|
Как подключить SMS-информирование
|
||||||
|
Расскажи про программу лояльности банка
|
||||||
|
Какие тарифы действуют для малого бизнеса
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Отложенный набор: составлен независимо от правил и не использовался для их настройки.
|
||||||
|
# Имена, формулировки и отрицательные примеры намеренно не пересекаются с основным
|
||||||
|
# набором. Формат тот же: {{ТИП:значение}}, строка без разметки — текст без ПД.
|
||||||
|
|
||||||
|
# --- ФИО: фамилии нетипичного словообразования ---
|
||||||
|
Поручитель {{FIO:Черных Андрей Петрович}} подтвердил согласие
|
||||||
|
Созаёмщик {{FIO:Долгих Мария Сергеевна}} подписала договор
|
||||||
|
Контактное лицо — {{FIO:Асланов Рустам Маратович}}
|
||||||
|
Ответственный исполнитель {{FIO:Петров-Водкин Сергей Иванович}}
|
||||||
|
Обратился {{FIO:Ким Сон Хо}} по вопросу перевода
|
||||||
|
Заявку подал {{FIO:Нгуен Ван Ань}}
|
||||||
|
Согласовано с {{FIO:Гаджиевым Артуром Рашидовичем}}
|
||||||
|
В списке значится {{FIO:Оганесян Карина Ашотовна}}
|
||||||
|
|
||||||
|
# --- ФИО: редкие отчества ---
|
||||||
|
Распоряжение подписал {{FIO:Владимир Ильич Ульянов}}
|
||||||
|
Приём ведёт {{FIO:Сергей Кузьмич Носов}}
|
||||||
|
Ответ дала {{FIO:Анна Фоминична Белова}}
|
||||||
|
Документ заверил {{FIO:Аполлинарий Никитич Греков}}
|
||||||
|
Обращение от {{FIO:Евлампии Саввичны Тороповой}}
|
||||||
|
|
||||||
|
# --- ФИО: падежи и порядок слов ---
|
||||||
|
Перевод в пользу {{FIO:Тихомировой Елены Владимировны}}
|
||||||
|
Доверенность на имя {{FIO:Родиона Захаровича Мельникова}}
|
||||||
|
Счёт открыт {{FIO:Яковом Демидовичем Соболевым}}
|
||||||
|
О {{FIO:Ларисе Тарасовне Гуровой}} сведений нет
|
||||||
|
{{FIO:Ульянова Злата}} записалась на консультацию
|
||||||
|
Заявление {{FIO:Милана Игнатьева}} рассмотрено
|
||||||
|
|
||||||
|
# --- ФИО: инициалы в разных написаниях ---
|
||||||
|
Утвердил {{FIO:Соболев Я. Д.}}
|
||||||
|
Исполнитель: {{FIO:Т.А. Гурова}}
|
||||||
|
Виза {{FIO:Мельников Р.З.}} получена
|
||||||
|
Согласование {{FIO:Е. В. Тихомирова}} завершено
|
||||||
|
|
||||||
|
# --- ФИО: регистр и явные якоря ---
|
||||||
|
Ф.И.О. заявителя: {{FIO:соболев яков демидович}}
|
||||||
|
ФИО {{FIO:ГУРОВА ЛАРИСА ТАРАСОВНА}}
|
||||||
|
Карта выпущена на имя {{FIO:мельников родион}}
|
||||||
|
Представитель {{FIO:Торопова Евлампия}} прибудет завтра
|
||||||
|
Владелец {{FIO:Греков Аполлинарий Никитич}}
|
||||||
|
|
||||||
|
# --- ФИО вместе с другими персональными данными ---
|
||||||
|
Клиент {{FIO:Носов Сергей Кузьмич}}, паспорт {{PASSPORT:45 09 № 123456}}, ИНН {{INN:7801234564}}
|
||||||
|
{{FIO:Белова Анна Фоминична}}, СНИЛС {{SNILS:216-048-357 45}}, телефон {{PHONE:8 (495) 123-45-67}}
|
||||||
|
Перевод от {{FIO:Тихомировой Е.В.}} на карту {{CARD:4276160012345675}}
|
||||||
|
{{FIO:Соболев Яков}}, дата рождения {{BIRTH_DATE:1985/05/12}}, место рождения {{BIRTH_PLACE:посёлок Лесной}}
|
||||||
|
Заявитель {{FIO:Гурова Л.Т.}}, почта {{EMAIL:L.GUROVA@EXAMPLE.CO.UK}}
|
||||||
|
{{FIO:Мельников Родион Захарович}}, адрес: г. {{ADDRESS_CITY:Санкт-Петербург}}, {{ADDRESS_STREET:Невский}} пр-т, д. {{ADDRESS_HOUSE:28}}
|
||||||
|
Держатель {{CARDHOLDER:PETR SOBOLEV}}, карта {{CARD:2200123456789019}}, код проверки {{CVV:456}}
|
||||||
|
{{FIO:Торопова Е.С.}}, водительское удостоверение {{DRIVER_LICENSE:78 12 345678}}
|
||||||
|
Клиент {{FIO:Греков А.Н.}}, гражданство {{CITIZENSHIP:Армении}}, дата выдачи {{PASSPORT_DATE:5 марта 1990 г.}}
|
||||||
|
|
||||||
|
# --- Несколько ФИО в одном тексте ---
|
||||||
|
Договор между {{FIO:Носовым Сергеем Кузьмичом}} и {{FIO:Беловой Анной Фоминичной}}
|
||||||
|
Присутствовали {{FIO:Соболев Я.Д.}}, {{FIO:Гурова Л.Т.}} и {{FIO:Мельников Р.З.}}
|
||||||
|
|
||||||
|
# --- Прочие типы ПД в новых написаниях ---
|
||||||
|
Телефон для связи {{PHONE:+7-916-123-45-67}}, запасной {{PHONE:89161234567}}
|
||||||
|
Почта {{EMAIL:ivan+bank@example.co.uk}} подтверждена
|
||||||
|
Паспорт серии {{PASSPORT:4509}} за номером {{PASSPORT:123456}}
|
||||||
|
Дата рождения {{BIRTH_DATE:12-05-1985}}, дата выдачи паспорта {{PASSPORT_DATE:2015.12.05}}
|
||||||
|
Родилась {{BIRTH_DATE:первого сентября 2001 года}} в {{BIRTH_PLACE:городе Вологда}}
|
||||||
|
Код подразделения {{DEPT_CODE:780 012}}, выдан {{PASSPORT_ISSUER:УМВД России по Санкт-Петербургу}}
|
||||||
|
Адрес регистрации: {{ADDRESS_POSTCODE:190000}}, город {{ADDRESS_CITY:Выборг}}, улица {{ADDRESS_STREET:Мира}}, дом {{ADDRESS_HOUSE:3}}, квартира {{ADDRESS_FLAT:12}}
|
||||||
|
Полис ОМС {{MEDICAL_POLICY:7801234567890123}} действителен
|
||||||
|
Загранпаспорт {{FOREIGN_PASSPORT:78 7654321}} получен
|
||||||
|
Военный билет {{MILITARY_ID:ВЕ 7654321}} сдан в архив
|
||||||
|
PIN {{PIN:4821}} к карте {{CARD:4276160012345675}} заблокирован
|
||||||
|
|
||||||
|
# --- Тексты без персональных данных ---
|
||||||
|
Третьяковская галерея открыта до восемнадцати часов
|
||||||
|
Разбери роман Булгакова про Воланда и Маргариту
|
||||||
|
Институт имени Курчатова объявил конкурс проектов
|
||||||
|
Улица Академика Королёва находится рядом с телецентром
|
||||||
|
Матч Спартак — Динамо перенесён на воскресенье
|
||||||
|
Бизнес-центр Романов Двор сдаётся в аренду
|
||||||
|
Аэропорт Шереметьево работает в обычном режиме
|
||||||
|
Ленинградский вокзал, платформа три, поезд опаздывает
|
||||||
|
Тургеневская библиотека закрыта на ремонт
|
||||||
|
Компания Яндекс открыла вакансию аналитика
|
||||||
|
Сбербанк Онлайн не открывается на старом телефоне
|
||||||
|
Премьер-министр выступил в парламенте с докладом
|
||||||
|
Площадь Восстания перекрыта из-за ремонта
|
||||||
|
Гостиница Космос принимает заявки на корпоратив
|
||||||
|
Какой лимит по кредитной карте можно установить
|
||||||
|
Объясни разницу между вкладом и накопительным счётом
|
||||||
|
Сформируй письмо клиентам об изменении тарифов
|
||||||
|
Напиши SQL-запрос для выборки активных договоров
|
||||||
|
Ошибка 404 при открытии страницы личного кабинета
|
||||||
|
Переведи сто долларов в рубли по курсу на сегодня
|
||||||
|
Собрание состоится в 15:30 в большой переговорной
|
||||||
|
Отчёт за 2024 год готов и отправлен на согласование
|
||||||
|
Сколько стоит выпуск дополнительной карты
|
||||||
|
Купи молоко, хлеб и сыр по дороге домой
|
||||||
|
Составь чек-лист для проверки договора аренды
|
||||||
|
Сгенерируй описание вакансии для младшего аналитика
|
||||||
|
Что такое овернайт и чем он отличается от вклада
|
||||||
|
Проверь орфографию и пунктуацию в этом абзаце
|
||||||
|
Расскажи про историю Невского проспекта
|
||||||
|
Опиши правила оформления командировочных расходов
|
||||||
|
Заказ 4276 1600 1234 5670 не прошёл проверку
|
||||||
|
Версия приложения 3.14.15 вышла вчера
|
||||||
|
Температура в Выборге опустилась до минус пяти
|
||||||
|
Подготовь презентацию на двадцать слайдов
|
||||||
|
Сравни два тарифных плана для малого бизнеса
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# КОНТРОЛЬНЫЙ НАБОР. Правила по нему не настраиваются — иначе он потеряет смысл,
|
||||||
|
# как это произошло с benchmark-holdout.txt. Замер по нему делается изредка, чтобы
|
||||||
|
# увидеть настоящее качество; дефекты, которые он вскрывает, чинятся по первым двум
|
||||||
|
# наборам или по собственным примерам, а не подгонкой под эти строки.
|
||||||
|
#
|
||||||
|
# Единственная правка, сделанная после его появления, — ограничение правила улицы:
|
||||||
|
# оно дожёвывало строку до конца и требовало адресного окружения. Дефект был виден
|
||||||
|
# без всякого набора, а числа по ФИО от него не изменились.
|
||||||
|
#
|
||||||
|
# Составлен после доработки правил по первому отложенному набору. Имена, формулировки
|
||||||
|
# и отрицательные примеры не пересекаются ни с одним из предыдущих наборов.
|
||||||
|
#
|
||||||
|
# Набор намеренно жёстче: сюда собраны случаи, которые раньше не проверялись —
|
||||||
|
# имена, совпадающие с обычными словами, двойные фамилии, подписи в письмах,
|
||||||
|
# короткие формы записи и внутренняя переписка банка.
|
||||||
|
|
||||||
|
# --- ФИО: имена, совпадающие с нарицательными словами ---
|
||||||
|
Заявку оформила {{FIO:Вера Никитична Пахомова}}
|
||||||
|
Обращение приняла {{FIO:Надежда Сергеевна Ефимова}}
|
||||||
|
Договор подписала {{FIO:Любовь Аркадьевна Зотова}}
|
||||||
|
Счёт открыт на {{FIO:Веру Пахомову}}
|
||||||
|
Перевод получила {{FIO:Надежда Ефимова}}
|
||||||
|
|
||||||
|
# --- ФИО: двойные и дефисные ---
|
||||||
|
Согласовано с {{FIO:Соловьёвым-Седым Игорем Львовичем}}
|
||||||
|
Заявитель {{FIO:Мамедов-оглы Рустам}}
|
||||||
|
Клиент {{FIO:Анна-Мария Ковач}} записалась на приём
|
||||||
|
Обращение от {{FIO:Петрова-Водкина С.И.}}
|
||||||
|
|
||||||
|
# --- ФИО: подписи и обращения ---
|
||||||
|
Уважаемый {{FIO:Игорь Львович}}, ваша заявка одобрена
|
||||||
|
С уважением, {{FIO:Ефимова Н.С.}}, отдел сопровождения
|
||||||
|
Исполнил: {{FIO:Зотова Л.А.}}, тел {{PHONE:+7 916 1234567}}
|
||||||
|
Прошу согласовать. {{FIO:Пахомов В.Н.}}
|
||||||
|
|
||||||
|
# --- ФИО: перечисления ---
|
||||||
|
В комиссию вошли {{FIO:Ефимов Р.Т.}}, {{FIO:Зотова Л.А.}}, {{FIO:Пахомов В.Н.}}
|
||||||
|
Ответственные: {{FIO:Игорь Соловьёв}} и {{FIO:Рустам Мамедов}}
|
||||||
|
|
||||||
|
# --- ФИО: внутренняя переписка ---
|
||||||
|
Коллеги, клиент {{FIO:Ковач Анна}} жалуется на списание
|
||||||
|
Передал заявку {{FIO:Ефимовой Надежде Сергеевне}} на проверку
|
||||||
|
Свяжитесь с {{FIO:Зотовой}} по вопросу тарифа
|
||||||
|
Заявка закрыта, обращался {{FIO:Пахомов Вениамин Николаевич}}
|
||||||
|
|
||||||
|
# --- ФИО вместе с другими персональными данными ---
|
||||||
|
Клиент {{FIO:Пахомов Вениамин Николаевич}}, паспорт {{PASSPORT:5003 987654}}, ИНН {{INN:502601234547}}
|
||||||
|
{{FIO:Ефимова Надежда Сергеевна}}, СНИЛС {{SNILS:389-104-256 97}}, карта {{CARD:5555 6666 7777 8884}}
|
||||||
|
{{FIO:Зотова Л.А.}}, дата рождения {{BIRTH_DATE:12.05.85}}, почта {{EMAIL:zotova_la@example.org}}
|
||||||
|
Перевод от {{FIO:Соловьёва Игоря}} на карту {{CARD:4173567812345677}}
|
||||||
|
{{FIO:Ковач Анна-Мария}}, адрес: {{ADDRESS_POSTCODE:141400}}, г. {{ADDRESS_CITY:Химки}}, ул. {{ADDRESS_STREET:Совхозная}}, д. {{ADDRESS_HOUSE:5}}, кв. {{ADDRESS_FLAT:88}}
|
||||||
|
Паспорт РФ {{PASSPORT:5003 987654}}, код подразделения {{DEPT_CODE:500-012}}, выдан {{PASSPORT_ISSUER:ГУ МВД России по Московской области}}
|
||||||
|
{{FIO:Мамедов Рустам}}, гражданство {{CITIZENSHIP:Азербайджана}}, место рождения {{BIRTH_PLACE:город Баку}}
|
||||||
|
Держатель {{CARDHOLDER:ANNA KOVACH}}, CVV {{CVV:731}}, карта {{CARD:5555 6666 7777 8884}}
|
||||||
|
{{FIO:Пахомов В.Н.}}, водительское удостоверение {{DRIVER_LICENSE:50 03 987654}}
|
||||||
|
Дата выдачи паспорта {{PASSPORT_DATE:2015-12-05}}, дата рождения {{BIRTH_DATE:тридцатого апреля 1978 года}}
|
||||||
|
|
||||||
|
# --- Прочие типы в коротких и небрежных записях ---
|
||||||
|
Телефон {{PHONE:8-916-123-45-67}}, запасной {{PHONE:+7(916)1234567}}
|
||||||
|
Почта для связи {{EMAIL:v.pahomov+bank@example.co.uk}}
|
||||||
|
ИНН {{INN:7801234564}} проверен, карта {{CARD:4173567812345677}} активна
|
||||||
|
Полис {{MEDICAL_POLICY:5026012345470123}} действует до конца года
|
||||||
|
Загранпаспорт {{FOREIGN_PASSPORT:50 9876543}} продлён
|
||||||
|
Индекс {{ADDRESS_POSTCODE:141400}}, город {{ADDRESS_CITY:Химки}}, улица {{ADDRESS_STREET:Совхозная}}
|
||||||
|
Родился {{BIRTH_DATE:5 марта 1978 г.}} в {{BIRTH_PLACE:посёлке Новогорск}}
|
||||||
|
Серия {{PASSPORT:5003}} номер {{PASSPORT:987654}}, дата выдачи {{PASSPORT_DATE:05.12.2015}}
|
||||||
|
|
||||||
|
# --- Тексты без персональных данных ---
|
||||||
|
Надежда умирает последней, как говорится
|
||||||
|
Любовь к искусству не измеряется деньгами
|
||||||
|
Вера в успех проекта у команды есть
|
||||||
|
Роза ветров показывает направление
|
||||||
|
Лилия расцвела в июне на клумбе
|
||||||
|
Ростов-на-Дону и Комсомольск-на-Амуре соединит рейс
|
||||||
|
Спартак Москва проиграл в гостях
|
||||||
|
Иван Грозный правил долго и жестоко
|
||||||
|
Пётр Первый основал город на Неве
|
||||||
|
Лада Веста подорожала на десять процентов
|
||||||
|
ООО Ромашка зарегистрировано в Москве
|
||||||
|
Иванов и партнёры — это юридическая фирма
|
||||||
|
Третий Рим — концепция шестнадцатого века
|
||||||
|
Красная площадь закрыта на репетицию
|
||||||
|
Нижний Тагил и Верхняя Пышма расположены рядом
|
||||||
|
Большой театр открывает сезон в сентябре
|
||||||
|
Институт Склифосовского принимает по скорой
|
||||||
|
Музей Тропинина работает по будням
|
||||||
|
Проспект Вернадского перекрыт до вечера
|
||||||
|
Улица Королёва названа в честь конструктора
|
||||||
|
Объясни, чем аннуитетный платёж отличается от дифференцированного
|
||||||
|
Составь скрипт разговора с клиентом по просроченной задолженности
|
||||||
|
Проверь, корректно ли заполнено платёжное поручение
|
||||||
|
Сформируй выгрузку по операциям за прошлый квартал
|
||||||
|
Напиши инструкцию по подключению эквайринга
|
||||||
|
Какие лимиты действуют на снятие наличных в банкомате
|
||||||
|
Опиши процесс рассмотрения заявки на ипотеку
|
||||||
|
Сравни условия по трём накопительным счетам
|
||||||
|
Подготовь ответ на претензию по списанию комиссии
|
||||||
|
Расшифруй аббревиатуру ОКВЭД и приведи пример
|
||||||
|
Сколько дней рассматривается заявление о возврате
|
||||||
|
Сгенерируй тестовые данные для проверки формы
|
||||||
|
Ошибка при оплате картой, код 05, что это значит
|
||||||
|
Переведи описание продукта на английский
|
||||||
|
Сократи текст уведомления до двухсот знаков
|
||||||
|
Проверь орфографию в шаблоне письма клиенту
|
||||||
|
Собрание перенесено на десятое число, кабинет 412
|
||||||
|
Отчёт по кварталу готов, направлен руководителю
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# ВТОРОЙ КОНТРОЛЬНЫЙ НАБОР. Составлен после того, как первый контрольный набор
|
||||||
|
# дважды повлиял на правила и перестал быть полностью независимым.
|
||||||
|
#
|
||||||
|
# Правила по нему не настраиваются. Замер делается изредка; дефекты, которые он
|
||||||
|
# вскрывает, чинятся по наборам отладки и по собственным примерам, а не подгонкой
|
||||||
|
# под эти строки. Любая правка, сделанная после его появления, записывается сюда.
|
||||||
|
#
|
||||||
|
# Правок пока не было.
|
||||||
|
#
|
||||||
|
# Бьёт в то, чего не было в предыдущих наборах: имена с частицами и в уменьшительной
|
||||||
|
# форме, строчная переписка без знаков препинания, структурные фрагменты, длинные
|
||||||
|
# числовые последовательности банковских документов, которые персональными данными
|
||||||
|
# не являются.
|
||||||
|
|
||||||
|
# --- ФИО: уменьшительные и разговорные формы ---
|
||||||
|
Клиент {{FIO:Ваня Селезнёв}} просит перевыпустить карту
|
||||||
|
Заявку принесла {{FIO:Саша Игнатова}}
|
||||||
|
Передайте {{FIO:Володе Черкасову}} документы на подпись
|
||||||
|
С {{FIO:Катей Рощиной}} согласовано по телефону
|
||||||
|
|
||||||
|
# --- ФИО: частицы и составные фамилии ---
|
||||||
|
Договор подписала {{FIO:Анна фон Штейн}}
|
||||||
|
Обратился {{FIO:Жан де Лавуа}} по вопросу счёта
|
||||||
|
Поручитель {{FIO:Абдул-Азиз Карибов}}
|
||||||
|
Клиент {{FIO:О'Коннор Патрик}} запросил выписку
|
||||||
|
|
||||||
|
# --- ФИО: строчная переписка без знаков препинания ---
|
||||||
|
фио клиента {{FIO:селезнёв иван васильевич}} карта перевыпущена
|
||||||
|
на имя {{FIO:рощина екатерина}} оформлен вклад
|
||||||
|
клиент {{FIO:игнатова александра петровна}} жалуется на комиссию
|
||||||
|
|
||||||
|
# --- ФИО: структурные фрагменты ---
|
||||||
|
ФИО: {{FIO:Черкасов В.П.}} | Телефон: {{PHONE:+7 495 000-11-22}} | Статус: активен
|
||||||
|
Заявитель — {{FIO:Рощина Екатерина Андреевна}}; решение — отказ
|
||||||
|
1. {{FIO:Селезнёв И.В.}} 2. {{FIO:Игнатова А.П.}} 3. {{FIO:Карибов А.А.}}
|
||||||
|
|
||||||
|
# --- ФИО: обращения и косвенные падежи ---
|
||||||
|
Уважаемая {{FIO:Екатерина Андреевна}}, ваш вклад продлён
|
||||||
|
Ответ направлен {{FIO:Селезнёву Ивану Васильевичу}}
|
||||||
|
О {{FIO:Черкасове Владимире}} сведений в базе нет
|
||||||
|
Претензия от {{FIO:Игнатовой А.П.}} рассмотрена
|
||||||
|
|
||||||
|
# --- ФИО вместе с другими персональными данными ---
|
||||||
|
{{FIO:Селезнёв Иван Васильевич}}, паспорт {{PASSPORT:7712 098765}}, ИНН {{INN:771209876500}}
|
||||||
|
{{FIO:Рощина Е.А.}}, СНИЛС {{SNILS:754-123-098 84}}, карта {{CARD:2200 1555 1234 5675}}
|
||||||
|
{{FIO:Черкасов Владимир Петрович}}, дата рождения {{BIRTH_DATE:22/09/1979}}
|
||||||
|
{{FIO:Игнатова А.П.}}, почта {{EMAIL:A.IGNATOVA2026@example.net}}, телефон {{PHONE:тел.: 4951234567}}
|
||||||
|
{{FIO:Карибов Абдул-Азиз}}, гражданство {{CITIZENSHIP:Таджикистана}}, место рождения {{BIRTH_PLACE:город Худжанд}}
|
||||||
|
Адрес: {{ADDRESS_POSTCODE:143026}}, {{ADDRESS_CITY:Одинцово}}, {{ADDRESS_STREET:Серебрякова}} проезд, дом {{ADDRESS_HOUSE:14}}, квартира {{ADDRESS_FLAT:203}}
|
||||||
|
Держатель {{CARDHOLDER:IVAN SELEZNEV}}, карта {{CARD:4155 2233 4455 6678}}, пин-код {{PIN:9012}}
|
||||||
|
Паспорт {{PASSPORT:7712 098765}} выдан {{PASSPORT_ISSUER:ГУ МВД России по городу Москве}}, дата выдачи {{PASSPORT_DATE:22.09.2019}}
|
||||||
|
{{FIO:Селезнёв И.В.}}, водительское удостоверение {{DRIVER_LICENSE:77 12 098765}}, полис {{MEDICAL_POLICY:7712098765004321}}
|
||||||
|
Родилась {{BIRTH_DATE:двадцать второго сентября 1979 года}} в {{BIRTH_PLACE:деревне Малые Вязёмы}}
|
||||||
|
|
||||||
|
# --- Прочие типы в непривычных написаниях ---
|
||||||
|
Телефон {{PHONE:+7 (495) 000-11-22}}, второй {{PHONE:8 495 0001122}}
|
||||||
|
Почта {{EMAIL:seleznev.i.v@example.co.uk}} подтверждена вчера
|
||||||
|
Загранпаспорт {{FOREIGN_PASSPORT:77 1209876}} выдан на десять лет
|
||||||
|
Военный билет {{MILITARY_ID:МУ 1209876}} сдан в отдел кадров
|
||||||
|
Свидетельство о рождении {{BIRTH_CERTIFICATE:IV-МЮ № 098765}}
|
||||||
|
Код подразделения {{DEPT_CODE:770-712}}, серия {{PASSPORT:7712}} номер {{PASSPORT:098765}}
|
||||||
|
|
||||||
|
# --- Тексты без персональных данных ---
|
||||||
|
Номер договора 7712-098765 от прошлого года
|
||||||
|
Расчётный счёт 40817810099910004312 открыт в отделении
|
||||||
|
ОГРН 1027700132195 указан в выписке
|
||||||
|
БИК 044525225 и корсчёт 30101810400000000225
|
||||||
|
Заказ 4155 2233 4455 6670 не прошёл проверку подлинности
|
||||||
|
Трек-номер RU123456789CN отслеживается на сайте
|
||||||
|
Адрес сервера 10.0.14.203 недоступен из внешней сети
|
||||||
|
Версия сборки 7.12.0 выкачена на прод
|
||||||
|
Тинькофф и Сбер объявили новые ставки
|
||||||
|
Форд и Мерседес подняли цены на обслуживание
|
||||||
|
Билайн сменил тарифную линейку
|
||||||
|
Чемпионат выиграл Магнус Карлсен в четвёртой партии
|
||||||
|
Роман Обломов читают в школе до сих пор
|
||||||
|
Печорин и Онегин — лишние люди русской литературы
|
||||||
|
Оперу Борис Годунов дают в субботу
|
||||||
|
Улица Серебрякова названа в честь художника
|
||||||
|
Проезд Черепановых переименован в прошлом году
|
||||||
|
Станция Ломоносовская закрыта на вход
|
||||||
|
Парк Горького работает круглосуточно
|
||||||
|
Аэропорт Домодедово сменил оператора
|
||||||
|
Больница имени Пирогова открыла новый корпус
|
||||||
|
Университет Лобачевского объявил набор
|
||||||
|
Сколько стоит перевыпуск карты при утере
|
||||||
|
Объясни разницу между лимитом и остатком по счёту
|
||||||
|
Составь памятку по безопасным покупкам в интернете
|
||||||
|
Проверь, почему не проходит платёж по реквизитам
|
||||||
|
Сформируй справку об отсутствии задолженности
|
||||||
|
Напиши текст смс о поступлении средств на счёт
|
||||||
|
Какие документы нужны для закрытия вклада досрочно
|
||||||
|
Подготовь сравнение двух ипотечных программ
|
||||||
|
Расшифруй код ошибки 51 при оплате картой
|
||||||
|
Переведи условия тарифа на простой язык
|
||||||
|
Сократи уведомление до ста двадцати знаков
|
||||||
|
Собрание правления перенесено на четверг
|
||||||
|
Отчёт о рисках направлен в комитет вчера
|
||||||
|
Смена руководителя отдела согласована приказом
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# НАБОР ПО ТИПАМ ПДН. Каждая строка содержит один тип ПДН в разметке {{ТИП:значение}}.
|
||||||
|
# Составлен для оценки эффективности детекции по каждому типу отдельно. Форматы
|
||||||
|
# и контрольные суммы взяты из проверенных наборов (benchmark-generated,
|
||||||
|
# benchmark-bank-context). Companion-типы (CVV, PIN, DATE, BIRTH_PLACE,
|
||||||
|
# ADDRESS_COUNTRY, ACCOUNT_NUMBER, BIK, OGRN, OGRNIP, KPP, INCOME, BIOMETRIC)
|
||||||
|
# маскируются только рядом с самостоятельными ПД, поэтому к ним добавлен ФИО.
|
||||||
|
# ADDRESS_REGION и ADDRESS_DISTRICT не имеют правил детекции (только модель),
|
||||||
|
# поэтому в набор не включены.
|
||||||
|
|
||||||
|
# --- ФИО ---
|
||||||
|
Заявление подал {{FIO:Смирнов Артём Викторович}}
|
||||||
|
{{FIO:Виктория Романовна Кузнецова}} оформила вклад
|
||||||
|
Документы подписал {{FIO:Соколов А.П.}}
|
||||||
|
Клиент {{FIO:Дмитрий Волков}} закрыл счёт
|
||||||
|
ФИО: {{FIO:Григорьев Максим Олегович}}
|
||||||
|
|
||||||
|
# --- Контакты ---
|
||||||
|
Почта клиента {{EMAIL:ivan.petrov@mail.ru}} для связи
|
||||||
|
Напишите на {{EMAIL:anna.smirnova@yandex.ru}} или позвоните
|
||||||
|
Телефон {{PHONE:+7 916 123-45-67}} для уточнения
|
||||||
|
Связаться по номеру {{PHONE:8-495-123-45-67}}
|
||||||
|
Мобильный {{PHONE:+7 (903) 111-22-33}} доступен днём
|
||||||
|
|
||||||
|
# --- Банковские карты ---
|
||||||
|
Карта {{CARD:4111 1111 1111 1111}} для оплаты
|
||||||
|
Номер карты {{CARD:5500 0000 0000 0004}} подтверждён
|
||||||
|
Платёж картой {{CARD:2964739560863550}} прошёл
|
||||||
|
Клиент {{FIO:Иванов Иван}}, CVV {{CVV:123}} на карте
|
||||||
|
Клиент {{FIO:Петров Пётр}}, пин-код {{PIN:4567}} от карты
|
||||||
|
Срок действия {{CARD_EXPIRY:09/27}} карты истекает
|
||||||
|
Держатель карты {{CARDHOLDER:IVAN PETROV}} указан на лицевой стороне
|
||||||
|
|
||||||
|
# --- Налоговые и страховые номера ---
|
||||||
|
ИНН {{INN:770301234550}} налогоплательщика
|
||||||
|
СНИЛС {{SNILS:112-233-445-95}} застрахованного лица
|
||||||
|
СНИЛС {{SNILS:661-058-551 91}} в системе
|
||||||
|
|
||||||
|
# --- Паспорт РФ ---
|
||||||
|
Паспорт {{PASSPORT:4509 123456}} выдан ОВД
|
||||||
|
Серия {{PASSPORT:45 09 № 123456}} паспорта
|
||||||
|
Паспорт {{PASSPORT:4509123456}} гражданина РФ
|
||||||
|
выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}} 12.05.2015
|
||||||
|
выдан {{PASSPORT_ISSUER:Отделом УФМС по Республике Татарстан}}, действителен
|
||||||
|
Дата выдачи {{PASSPORT_DATE:12.05.2015}} паспорта
|
||||||
|
Код подразделения {{DEPT_CODE:770-123}} указан в паспорте
|
||||||
|
|
||||||
|
# --- Другие документы ---
|
||||||
|
Водительское удостоверение {{DRIVER_LICENSE:77 12 345678}} действительно
|
||||||
|
Загранпаспорт {{FOREIGN_PASSPORT:71 1234567}} выдан консульством
|
||||||
|
Военный билет {{MILITARY_ID:АБ 1234567}} на учёте
|
||||||
|
Свидетельство о рождении {{BIRTH_CERTIFICATE:II-МЮ 123456}} выдано ЗАГС
|
||||||
|
Полис ОМС {{MEDICAL_POLICY:1234567890123456}} действует
|
||||||
|
|
||||||
|
# --- Дата и место рождения ---
|
||||||
|
Дата рождения {{BIRTH_DATE:12.05.1985}} клиента
|
||||||
|
Родился {{BIRTH_DATE:15.03.1990}} в городе
|
||||||
|
Клиент {{FIO:Смирнов Артём}}, место рождения {{BIRTH_PLACE:город Тверь}}
|
||||||
|
Клиент {{FIO:Волков Игорь}}, родился в {{BIRTH_PLACE:Новосибирске}}
|
||||||
|
Гражданство {{CITIZENSHIP:РФ}}
|
||||||
|
Гражданин {{CITIZENSHIP:России}}
|
||||||
|
Гражданство {{CITIZENSHIP:Республики Беларусь}}
|
||||||
|
Клиент {{FIO:Петров Пётр}}, дата {{DATE:01.02.2025}} обращения
|
||||||
|
|
||||||
|
# --- Адрес ---
|
||||||
|
Клиент {{FIO:Смирнов Артём}}, страна проживания {{ADDRESS_COUNTRY:Казахстан}}
|
||||||
|
Индекс {{ADDRESS_POSTCODE:125009}} почтовый
|
||||||
|
Город {{ADDRESS_CITY:Москва}} регистрации
|
||||||
|
Улица {{ADDRESS_STREET:Тверская}} дом
|
||||||
|
Дом {{ADDRESS_HOUSE:7}} корпус
|
||||||
|
Квартира {{ADDRESS_FLAT:15}} в доме
|
||||||
|
|
||||||
|
# --- Банковские реквизиты ---
|
||||||
|
Клиент {{FIO:Волков Игорь}}, расчётный счёт {{ACCOUNT_NUMBER:40702810500000001234}}
|
||||||
|
Клиент {{FIO:Петров Пётр}}, р/с {{ACCOUNT_NUMBER:40702810000000001234}} в банке
|
||||||
|
Клиент {{FIO:Смирнов Артём}}, БИК {{BIK:044525593}} банка-получателя
|
||||||
|
Директор {{FIO:Волков И.П.}}, ОГРН {{OGRN:1027700132195}} организации
|
||||||
|
ИП {{FIO:Волков Игорь Петрович}}, ОГРНИП {{OGRNIP:304500116000157}}
|
||||||
|
Заявитель {{FIO:Волкова Д.И.}}, КПП {{KPP:770101001}} в реквизитах счёта
|
||||||
|
|
||||||
|
# --- Доход и биометрия ---
|
||||||
|
Клиент {{FIO:Смирнов Артём}}, доход {{INCOME:95 000}} руб. подтверждён справкой
|
||||||
|
Клиент {{FIO:Петров Пётр}}, заработная плата {{INCOME:150000}} перечисляется на карту
|
||||||
|
Клиент {{FIO:Волков Игорь}} прошёл верификацию через {{BIOMETRIC:биометрические данные}}
|
||||||
|
Клиент {{FIO:Петров Пётр}}, оформлен {{BIOMETRIC:слепок голоса}} для системы {{BIOMETRIC:ЕБС}}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Размеченный набор для замера качества детекции.
|
||||||
|
#
|
||||||
|
# Формат: персональные данные обёрнуты в {{ТИП:значение}}. Строка без разметки —
|
||||||
|
# текст, в котором ПД нет вовсе, и любое срабатывание на ней считается ложным.
|
||||||
|
# Строки, начинающиеся с #, и пустые строки пропускаются.
|
||||||
|
#
|
||||||
|
# Набор смещён в сторону ФИО: это самый сложный для распознавания тип и главный
|
||||||
|
# источник ложных срабатываний.
|
||||||
|
|
||||||
|
# --- ФИО: полное имя с отчеством, разные падежи ---
|
||||||
|
Клиент {{FIO:Иванов Иван Иванович}} обратился в отделение
|
||||||
|
Заявление подал {{FIO:Петров Сергей Николаевич}}
|
||||||
|
{{FIO:Сидорова Анна Петровна}} подтвердила согласие на обработку
|
||||||
|
Договор подписан с {{FIO:Кузнецовым Дмитрием Алексеевичем}}
|
||||||
|
Передайте документы {{FIO:Смирнову Олегу Ивановичу}}
|
||||||
|
Встреча с {{FIO:Ивановым Иваном Ивановичем}} перенесена на следующую неделю
|
||||||
|
Ответственный — {{FIO:Николаев Юрий Витальевич}}
|
||||||
|
Доверенность выдана на {{FIO:Белова Глеба Артёмовича}}
|
||||||
|
{{FIO:Троицкий Лев Маркович}} назначен ответственным
|
||||||
|
{{FIO:Иванидзе Нина Георгиевна}} подала обращение
|
||||||
|
Поручение принято от {{FIO:Ясенева Марка Тимуровича}}
|
||||||
|
Согласовано с {{FIO:Лаврентьевой Ольгой Никитичной}}
|
||||||
|
|
||||||
|
# --- ФИО: имя и отчество без фамилии ---
|
||||||
|
{{FIO:Иван Иванович}} ждёт ответа
|
||||||
|
Пригласите {{FIO:Ивана Сергеевича}} на встречу
|
||||||
|
Спросите у {{FIO:Марии Никитичны}} про заявку
|
||||||
|
Свяжитесь с {{FIO:Анной Ивановной}} по вопросу перевода
|
||||||
|
Документы передал {{FIO:Пётр Глебович}}
|
||||||
|
|
||||||
|
# --- ФИО: фамилия с инициалами ---
|
||||||
|
Заявление от {{FIO:Иванов И.И.}} принято к рассмотрению
|
||||||
|
Подписал {{FIO:И.И. Иванов}}
|
||||||
|
Согласовано: {{FIO:Петрова А.С.}}
|
||||||
|
Исполнитель {{FIO:Кузнецов Д.А.}}, срок до конца недели
|
||||||
|
Отчёт подготовил {{FIO:С.Н. Мещеряков}}
|
||||||
|
|
||||||
|
# --- ФИО: строчными и прописными буквами ---
|
||||||
|
ФИО: {{FIO:иванов иван иванович}}
|
||||||
|
фио: {{FIO:петрова анна ивановна}}
|
||||||
|
Ф.И.О. {{FIO:смирнов олег петрович}}
|
||||||
|
Карта оформлена на имя {{FIO:петров сергей}}
|
||||||
|
{{FIO:ИВАНОВ ИВАН ИВАНОВИЧ}} обратился повторно
|
||||||
|
Заявитель: {{FIO:КУЗНЕЦОВА АННА СЕРГЕЕВНА}}
|
||||||
|
|
||||||
|
# --- ФИО: фамилия рядом с личным именем ---
|
||||||
|
{{FIO:Сергей Петров}} подтвердил заявку
|
||||||
|
{{FIO:Петров Сергей}} подтвердил заявку
|
||||||
|
Обращение {{FIO:Ольги Ковалёвой}} рассмотрено
|
||||||
|
Перевод получил {{FIO:Артём Тихомиров}}
|
||||||
|
Заявку оформила {{FIO:Марина Шевченко}}
|
||||||
|
Поручителем выступает {{FIO:Тимур Мкртчян}}
|
||||||
|
В списке значится {{FIO:Екатерина Бурмистрова}}
|
||||||
|
Счёт открыт на {{FIO:Никиту Кольцова}}
|
||||||
|
|
||||||
|
# --- ФИО: после ролевого слова ---
|
||||||
|
Клиент {{FIO:Петров Сергей}}, заявка одобрена
|
||||||
|
Плательщик {{FIO:Ковалёва}} подтвердила перевод
|
||||||
|
Сотрудник {{FIO:Николаев Юрий}} оформил заявку
|
||||||
|
Заказчик {{FIO:Шевченко Марина}} согласовал смету
|
||||||
|
Пациент {{FIO:Ким Елена}} записан на приём
|
||||||
|
Абонент {{FIO:Белов Глеб}} не отвечает
|
||||||
|
Владелец счёта {{FIO:Романов Артём Игоревич}}
|
||||||
|
Получатель {{FIO:Ясенев Марк}} подтвердил адрес
|
||||||
|
Действует в лице {{FIO:Иванова Ивана Ивановича}}
|
||||||
|
|
||||||
|
# --- ФИО вместе с другими персональными данными ---
|
||||||
|
Клиент {{FIO:Иванов Иван Иванович}}, паспорт {{PASSPORT:4509 123456}}, тел {{PHONE:+7 916 123-45-67}}
|
||||||
|
{{FIO:Петров С.А.}}, ИНН {{INN:770301234550}}, карта {{CARD:4111 1111 1111 1111}}
|
||||||
|
Почта {{EMAIL:ivan.petrov@mail.ru}} принадлежит клиенту {{FIO:Петрову Ивану}}
|
||||||
|
{{FIO:Сидорова Анна Петровна}}, дата рождения {{BIRTH_DATE:12.05.1985}}, гражданство {{CITIZENSHIP:РФ}}
|
||||||
|
Клиент {{FIO:Кузнецов Дмитрий Алексеевич}}, СНИЛС {{SNILS:112-233-445 95}}
|
||||||
|
Оформлено на {{FIO:Смирнова Олега}}, водительское удостоверение {{DRIVER_LICENSE:9902 123456}}
|
||||||
|
{{FIO:Белова Анна Ивановна}}, место рождения {{BIRTH_PLACE:город Тверь}}
|
||||||
|
Заявитель {{FIO:Троицкий Лев}}, загранпаспорт {{FOREIGN_PASSPORT:75 1234567}}
|
||||||
|
Клиент {{FIO:Александр Пушкин}}, паспорт {{PASSPORT:4509 123456}}
|
||||||
|
Держатель {{CARDHOLDER:IVAN PETROV}}, карта {{CARD:4111 1111 1111 1111}}, CVV {{CVV:123}}
|
||||||
|
|
||||||
|
# --- Несколько имён в одном тексте ---
|
||||||
|
Встречу проводят {{FIO:Иванов Иван Иванович}} и {{FIO:Петрова Анна Сергеевна}}
|
||||||
|
Согласовали {{FIO:Кузнецов Д.А.}} и {{FIO:Смирнов О.И.}}, возражений нет
|
||||||
|
|
||||||
|
# --- Прочие типы ПД для замера точности в реальном окружении ---
|
||||||
|
Паспорт {{PASSPORT:4509 123456}} выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}}, код подразделения {{DEPT_CODE:770-001}}
|
||||||
|
Серия {{PASSPORT:4509}} номер {{PASSPORT:123456}}, дата выдачи {{PASSPORT_DATE:12.05.2015}}
|
||||||
|
Адрес: {{ADDRESS_POSTCODE:125009}}, г. {{ADDRESS_CITY:Москва}}, ул. {{ADDRESS_STREET:Тверская}}, д. {{ADDRESS_HOUSE:7}}, кв. {{ADDRESS_FLAT:15}}
|
||||||
|
Свяжитесь: {{EMAIL:ivan.petrov@mail.ru}} или {{PHONE:+7 (916) 123-45-67}}
|
||||||
|
Дата рождения {{BIRTH_DATE:12 мая 1985 года}}
|
||||||
|
Дата рождения {{BIRTH_DATE:двенадцатого мая тысяча девятьсот восемьдесят пятого года}}
|
||||||
|
Военный билет {{MILITARY_ID:АБ 1234567}} предъявлен
|
||||||
|
Полис ОМС {{MEDICAL_POLICY:1234567890123456}} оформлен
|
||||||
|
Свидетельство о рождении {{BIRTH_CERTIFICATE:II-МЮ № 123456}}
|
||||||
|
ИНН {{INN:770301234550}} проверен в реестре
|
||||||
|
Карта {{CARD:5555 5555 5555 4444}} заблокирована
|
||||||
|
Гражданство {{CITIZENSHIP:Республики Беларусь}}, место рождения {{BIRTH_PLACE:город Минск}}
|
||||||
|
|
||||||
|
# --- Тексты без персональных данных: любое срабатывание здесь ложное ---
|
||||||
|
Напиши стихотворение в духе Александра Пушкина про осень
|
||||||
|
Сравни Толстого и Достоевского как прозаиков
|
||||||
|
Когда Гагарин полетел в космос
|
||||||
|
Расскажи о теории Менделеева про периодический закон
|
||||||
|
Разбери стихотворение Лермонтова Парус
|
||||||
|
Кто написал оперу Евгений Онегин
|
||||||
|
Московский Кремль открыт для посещения
|
||||||
|
Экскурсия в Нижний Новгород перенесена на май
|
||||||
|
Смоленская площадь закрыта на ремонт
|
||||||
|
Улица Тверская и Кутузовский проспект перекрыты
|
||||||
|
Театр имени Вахтангова открыл сезон
|
||||||
|
Премия имени Ломоносова вручена в декабре
|
||||||
|
Площадь Гагарина находится на юго-западе
|
||||||
|
Отделение банка на улице Тверская, дом 7 работает до 20:00
|
||||||
|
Дополнительный офис, г. Москва, ул. Арбат, д. 1
|
||||||
|
Банкомат по адресу Ленинский проспект, дом 30 не работает
|
||||||
|
Банк Открытие подтвердил лимит по овердрафту
|
||||||
|
Сбербанк России опубликовал квартальный отчёт
|
||||||
|
Газпром и Роснефть подписали соглашение
|
||||||
|
Встреча перенесена на 12.05.2025, подтвердите участие
|
||||||
|
Сервер 192.168.1 отвечает, сборка 1.2.3 развёрнута
|
||||||
|
Заказ 1234 5678 9012 3456 отгружен со склада
|
||||||
|
Пин-код 1234 введён неверно, попробуйте снова
|
||||||
|
Компания выросла на 20 процентов в 2024 году
|
||||||
|
В Понедельник Отдел Согласует Договор
|
||||||
|
Расскажи о погоде в Москве завтра
|
||||||
|
Объясни разницу между дебетовой и кредитной картой
|
||||||
|
Подготовь план обучения для команды разработки
|
||||||
|
Какие документы нужны для открытия счёта юридическому лицу
|
||||||
|
Сколько стоит обслуживание карты в год
|
||||||
|
Переведи текст на английский язык
|
||||||
|
Сгенерируй описание вакансии для аналитика данных
|
||||||
|
Что такое эквайринг и как он работает
|
||||||
|
Составь список задач на следующий спринт
|
||||||
|
Проверь орфографию в этом абзаце
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"default": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": true,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"crm": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "TOKEN",
|
||||||
|
"types": [
|
||||||
|
"FIO",
|
||||||
|
"PHONE",
|
||||||
|
"EMAIL"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "SYNTHETIC",
|
||||||
|
"types": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"disabled": {
|
||||||
|
"enabled": false,
|
||||||
|
"demask": false,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": true,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": [
|
||||||
|
"*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"guarded": {
|
||||||
|
"enabled": true,
|
||||||
|
"demask": true,
|
||||||
|
"maskMode": "MASK",
|
||||||
|
"types": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"key": "s3cret-key-2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Загрузка модели распознавания для второй ступени.
|
||||||
|
#
|
||||||
|
# Модель — BERT (onnx-community/bert-base-NER-Russian-ONNX, лицензия MIT): размечает
|
||||||
|
# имена и составляющие адреса вплоть до региона и района. Около 170 МБ, в репозиторий
|
||||||
|
# не кладётся.
|
||||||
|
#
|
||||||
|
# ./tools/fetch-ner-model.sh
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
BASE=https://huggingface.co/onnx-community/bert-base-NER-Russian-ONNX/resolve/main
|
||||||
|
DIR=models/rubert-ner
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
|
||||||
|
echo "Загрузка модели в $DIR (около 170 МБ)"
|
||||||
|
curl -sSL --progress-bar -o "$DIR/model_int8.onnx" "$BASE/onnx/model_int8.onnx"
|
||||||
|
for f in vocab.txt config.json tokenizer.json tokenizer_config.json; do
|
||||||
|
curl -sSL -o "$DIR/$f" "$BASE/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
ls -lh "$DIR"
|
||||||
|
echo
|
||||||
|
echo "Готово. Включить: pdguard.ner.engine=rubert, pdguard.ner.model=$DIR"
|
||||||
Reference in New Issue
Block a user