fix: speed up academic degree filter

This commit is contained in:
2026-08-26 17:59:05 +03:00
parent 8351823bf6
commit aac909d0ef
12 changed files with 78 additions and 56 deletions

View File

@@ -51,10 +51,20 @@ def _ensure_runtime_schema() -> None:
missing_columns.append("profile_unavailable_streak INTEGER NOT NULL DEFAULT 0") missing_columns.append("profile_unavailable_streak INTEGER NOT NULL DEFAULT 0")
if "last_profile_check_at" not in employee_columns: if "last_profile_check_at" not in employee_columns:
missing_columns.append("last_profile_check_at TIMESTAMPTZ") missing_columns.append("last_profile_check_at TIMESTAMPTZ")
if "has_academic_degree" not in employee_columns:
missing_columns.append("has_academic_degree BOOLEAN NOT NULL DEFAULT FALSE")
if missing_columns: if missing_columns:
with engine.begin() as connection: with engine.begin() as connection:
for column in missing_columns: for column in missing_columns:
connection.execute(text(f"ALTER TABLE employees ADD COLUMN {column}")) connection.execute(text(f"ALTER TABLE employees ADD COLUMN {column}"))
if engine.dialect.name == "postgresql":
connection.execute(
text(
"UPDATE employees SET has_academic_degree = "
"COALESCE(current_data::text ~* '(кандидат|доктор).{0,80}наук|ph\\.?[[:space:]]*d\\.?', FALSE)"
)
)
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_employees_has_academic_degree ON employees (has_academic_degree)"))
if "crawl_runs" not in table_names: if "crawl_runs" not in table_names:
return return
crawl_run_columns = {column["name"] for column in inspector.get_columns("crawl_runs")} crawl_run_columns = {column["name"] for column in inspector.get_columns("crawl_runs")}

View File

@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON from sqlalchemy.types import JSON
@@ -21,6 +21,7 @@ class Employee(Base):
UniqueConstraint("profile_key", name="uq_employees_profile_key"), UniqueConstraint("profile_key", name="uq_employees_profile_key"),
Index("ix_employees_full_name", "full_name"), Index("ix_employees_full_name", "full_name"),
Index("ix_employees_status", "status"), Index("ix_employees_status", "status"),
Index("ix_employees_has_academic_degree", "has_academic_degree"),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -37,6 +38,7 @@ class Employee(Base):
last_profile_check_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_profile_check_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
parser_version: Mapped[str | None] = mapped_column(String(32)) parser_version: Mapped[str | None] = mapped_column(String(32))
current_data: Mapped[dict | None] = mapped_column(json_type) current_data: Mapped[dict | None] = mapped_column(json_type)
has_academic_degree: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
current_checksum: Mapped[str | None] = mapped_column(String(64)) current_checksum: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)

View File

@@ -0,0 +1,23 @@
import re
from typing import Any
_PATTERN = re.compile(r"\b(?:кандидат|доктор)\s+[\w\s-]{0,80}?\s+наук\b|\bph\.?\s*d\.?\b", re.IGNORECASE)
def academic_degrees(data: dict[str, Any] | None) -> list[str]:
degrees = []
for section in (data or {}).get("sections") or []:
if not isinstance(section, dict) or not re.search(r"уч[её]н.*степен|academic degree", str(section.get("title") or ""), re.IGNORECASE):
continue
values = [
*(entry.get("text") for entry in section.get("year_entries") or [] if isinstance(entry, dict)),
*(section.get("paragraphs") or []),
*(section.get("items") or []),
section.get("raw_text"),
]
for value in values:
text = str(value or "").strip()
if text and _PATTERN.search(text) and text not in degrees:
degrees.append(text)
return degrees

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import re
from datetime import date, datetime, time from datetime import date, datetime, time
from math import ceil from math import ceil
from typing import Any from typing import Any
@@ -10,6 +9,7 @@ from sqlalchemy import Select, Text, and_, desc, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import CrawlError, CrawlRun, CrawlRunEmployeeChange, Employee, EmployeeNewsLink from app.models import CrawlError, CrawlRun, CrawlRunEmployeeChange, Employee, EmployeeNewsLink
from app.services.academic_degrees import academic_degrees
EMPLOYEE_SORTS = { EMPLOYEE_SORTS = {
"full_name": Employee.full_name, "full_name": Employee.full_name,
@@ -20,9 +20,6 @@ EMPLOYEE_SORTS = {
"hse_start_year": Employee.current_data["hse_start_year"].as_integer(), "hse_start_year": Employee.current_data["hse_start_year"].as_integer(),
} }
_ACADEMIC_DEGREE_PATTERN = re.compile(r"\b(?:кандидат|доктор)\s+[\w\s-]{0,80}?\s+наук\b|\bph\.?\s*d\.?\b", re.IGNORECASE)
def employee_display_payload(employee: Employee) -> dict[str, Any]: def employee_display_payload(employee: Employee) -> dict[str, Any]:
data = _as_dict(employee.current_data) data = _as_dict(employee.current_data)
contacts = _as_dict(data.get("contacts")) contacts = _as_dict(data.get("contacts"))
@@ -31,7 +28,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
positions = _clean_list(data.get("positions")) positions = _clean_list(data.get("positions"))
emails = _clean_list(contacts.get("emails")) emails = _clean_list(contacts.get("emails"))
phones = _clean_list(contacts.get("phones")) phones = _clean_list(contacts.get("phones"))
academic_degrees = _academic_degrees(sections) degree_values = academic_degrees(data)
return { return {
"id": employee.id, "id": employee.id,
"full_name": employee.full_name, "full_name": employee.full_name,
@@ -46,7 +43,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
"phones": phones, "phones": phones,
"phone_text": ", ".join(phones), "phone_text": ", ".join(phones),
"address": contacts.get("address"), "address": contacts.get("address"),
"academic_degree_text": "; ".join(academic_degrees), "academic_degree_text": "; ".join(degree_values),
"publications_count": _count_section_items(sections, "publications"), "publications_count": _count_section_items(sections, "publications"),
"courses_count": _count_section_items(sections, "courses_by_year"), "courses_count": _count_section_items(sections, "courses_by_year"),
"news_count": len(stored_news_links) or _count_section_items(sections, "news"), "news_count": len(stored_news_links) or _count_section_items(sections, "news"),
@@ -104,14 +101,7 @@ def build_employee_query(
elif has_email is False: elif has_email is False:
filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%"))) filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%")))
if has_academic_degree is not None: if has_academic_degree is not None:
data_text = Employee.current_data.cast(Text) filters.append(Employee.has_academic_degree.is_(has_academic_degree))
degree_condition = or_(
and_(_json_text_contains(data_text, "кандидат"), _json_text_contains(data_text, "наук")),
and_(_json_text_contains(data_text, "доктор"), _json_text_contains(data_text, "наук")),
_json_text_contains(data_text, "phd"),
_json_text_contains(data_text, "ph.d."),
)
filters.append(degree_condition if has_academic_degree else or_(Employee.current_data.is_(None), ~degree_condition))
if filters: if filters:
stmt = stmt.where(and_(*filters)) stmt = stmt.where(and_(*filters))
return stmt return stmt
@@ -235,36 +225,6 @@ def format_admin_datetime(value: Any) -> str:
return value.strftime("%d.%m.%Y %H:%M") return value.strftime("%d.%m.%Y %H:%M")
def _academic_degrees(sections: list[Any]) -> list[str]:
degrees = []
for section in sections:
section_data = _as_dict(section)
title = str(section_data.get("title") or "")
if not re.search(r"уч[её]н.*степен|academic degree", title, re.IGNORECASE):
continue
values = [
*(_as_dict(entry).get("text") for entry in _as_list(section_data.get("year_entries"))),
*_clean_list(section_data.get("paragraphs")),
*_clean_list(section_data.get("items")),
section_data.get("raw_text"),
]
for value in values:
text = str(value or "").strip()
if text and _ACADEMIC_DEGREE_PATTERN.search(text) and text not in degrees:
degrees.append(text)
return degrees
def _json_text_contains(data_text: Any, value: str) -> Any:
escaped = value.encode("unicode_escape").decode("ascii")
escaped_capitalized = value.capitalize().encode("unicode_escape").decode("ascii")
return or_(
data_text.ilike(f"%{value}%"),
data_text.ilike(f"%{escaped}%"),
data_text.ilike(f"%{escaped_capitalized}%"),
)
def _employee_status_display(status: str | None) -> str: def _employee_status_display(status: str | None) -> str:
labels = {"active": "Работает", "verification_required": "Требует проверки", "dismissed": "Уволен"} labels = {"active": "Работает", "verification_required": "Требует проверки", "dismissed": "Уволен"}
return labels.get(status or "", status or "Не указано") return labels.get(status or "", status or "Не указано")

View File

@@ -25,6 +25,7 @@ from app.models import (
from app.parser.collector import collect_profile_links from app.parser.collector import collect_profile_links
from app.parser.profile import parse_person_profile from app.parser.profile import parse_person_profile
from app.parser.profile_url import profile_key from app.parser.profile_url import profile_key
from app.services.academic_degrees import academic_degrees
from app.services.dataset_versions import get_or_create_current_version from app.services.dataset_versions import get_or_create_current_version
from app.services.resource_cache import ResourceCache from app.services.resource_cache import ResourceCache
@@ -265,6 +266,7 @@ def _upsert_employee(db: Session, run: CrawlRun, parsed: dict) -> tuple[Employee
employee.profile_unavailable_streak = 0 employee.profile_unavailable_streak = 0
employee.last_profile_check_at = now employee.last_profile_check_at = now
employee.parser_version = parser_version employee.parser_version = parser_version
employee.has_academic_degree = bool(academic_degrees(parsed))
if changed: if changed:
employee.current_data = parsed employee.current_data = parsed
employee.current_checksum = checksum employee.current_checksum = checksum

View File

@@ -1,3 +1,3 @@
APP_VERSION = "0.7.3" APP_VERSION = "0.7.4"
FRONTEND_VERSION = "0.7.3" FRONTEND_VERSION = "0.7.4"
BACKEND_VERSION = "0.7.3" BACKEND_VERSION = "0.7.4"

View File

@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS employees (
dismissed_at TIMESTAMPTZ, dismissed_at TIMESTAMPTZ,
parser_version VARCHAR(32), parser_version VARCHAR(32),
current_data JSONB, current_data JSONB,
has_academic_degree BOOLEAN NOT NULL DEFAULT FALSE,
current_checksum VARCHAR(64), current_checksum VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -40,6 +41,7 @@ CREATE TABLE IF NOT EXISTS employees (
CREATE INDEX IF NOT EXISTS ix_employees_full_name ON employees (full_name); CREATE INDEX IF NOT EXISTS ix_employees_full_name ON employees (full_name);
CREATE INDEX IF NOT EXISTS ix_employees_status ON employees (status); CREATE INDEX IF NOT EXISTS ix_employees_status ON employees (status);
CREATE INDEX IF NOT EXISTS ix_employees_has_academic_degree ON employees (has_academic_degree);
CREATE TABLE IF NOT EXISTS employee_snapshots ( CREATE TABLE IF NOT EXISTS employee_snapshots (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,

View File

@@ -0,0 +1,11 @@
ALTER TABLE employees
ADD COLUMN IF NOT EXISTS has_academic_degree BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE employees
SET has_academic_degree = COALESCE(
current_data::text ~* '(кандидат|доктор).{0,80}наук|ph\\.?[[:space:]]*d\\.?',
FALSE
);
CREATE INDEX IF NOT EXISTS ix_employees_has_academic_degree
ON employees (has_academic_degree);

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "miem-workers" name = "miem-workers"
version = "0.7.3" version = "0.7.4"
description = "MIEM employees parser, admin API, and MCP server" description = "MIEM employees parser, admin API, and MCP server"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -61,6 +61,7 @@ def test_list_employees_page_filters_and_displays_academic_degrees(db_session):
status="active", status="active",
first_seen_at=datetime.now(timezone.utc), first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc), last_seen_at=datetime.now(timezone.utc),
has_academic_degree=True,
current_data={ current_data={
"sections": [ "sections": [
{ {
@@ -77,6 +78,7 @@ def test_list_employees_page_filters_and_displays_academic_degrees(db_session):
status="active", status="active",
first_seen_at=datetime.now(timezone.utc), first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc), last_seen_at=datetime.now(timezone.utc),
has_academic_degree=False,
current_data={"sections": [{"title": "Образование", "items": ["Магистратура"]}]}, current_data={"sections": [{"title": "Образование", "items": ["Магистратура"]}]},
), ),
] ]
@@ -89,6 +91,10 @@ def test_list_employees_page_filters_and_displays_academic_degrees(db_session):
assert page["employees"][0]["full_name"] == "Doctor" assert page["employees"][0]["full_name"] == "Doctor"
assert page["employees"][0]["academic_degree_text"] == "Доктор технических наук" assert page["employees"][0]["academic_degree_text"] == "Доктор технических наук"
page = list_employees_page(db_session, has_academic_degree=False)
assert page["employees"][0]["full_name"] == "Master"
def test_employee_detail_payload_normalizes_human_readable_sections(db_session): def test_employee_detail_payload_normalizes_human_readable_sections(db_session):
employee = Employee( employee = Employee(

View File

@@ -273,7 +273,7 @@ def test_upsert_employee_increments_new_count_and_records_change_for_new_employe
db_session.add(run) db_session.add(run)
db_session.commit() db_session.commit()
_upsert_employee( employee, _ = _upsert_employee(
db_session, db_session,
run, run,
{ {
@@ -282,7 +282,12 @@ def test_upsert_employee_increments_new_count_and_records_change_for_new_employe
"profile_id": "newperson", "profile_id": "newperson",
"full_name": "New Person", "full_name": "New Person",
"tabs": [], "tabs": [],
"sections": [], "sections": [
{
"title": "Образование и учёные степени",
"year_entries": [{"text": "Кандидат технических наук"}],
}
],
"parser_version": "0.2.0", "parser_version": "0.2.0",
"_html": "<html></html>", "_html": "<html></html>",
}, },
@@ -290,6 +295,7 @@ def test_upsert_employee_increments_new_count_and_records_change_for_new_employe
db_session.commit() db_session.commit()
assert run.new_count == 1 assert run.new_count == 1
assert employee.has_academic_degree is True
change = db_session.query(CrawlRunEmployeeChange).one() change = db_session.query(CrawlRunEmployeeChange).one()
assert change.change_type == "new" assert change.change_type == "new"
assert change.full_name == "New Person" assert change.full_name == "New Person"

View File

@@ -140,5 +140,5 @@ def test_runtime_schema_adds_profile_verification_fields(monkeypatch):
inspector = inspect(engine) inspector = inspect(engine)
columns = {column["name"] for column in inspector.get_columns("employees")} columns = {column["name"] for column in inspector.get_columns("employees")}
assert {"profile_unavailable_streak", "last_profile_check_at"}.issubset(columns) assert {"profile_unavailable_streak", "last_profile_check_at", "has_academic_degree"}.issubset(columns)
assert "employee_profile_urls" in inspector.get_table_names() assert "employee_profile_urls" in inspector.get_table_names()