Compare commits
13 Commits
feature/ac
...
fix/restor
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bdb8b1d90 | |||
| f3fb714126 | |||
| 3ed1dd8832 | |||
| 3c2057dcec | |||
| b028d81e9d | |||
| 9eb7c3b3f8 | |||
| 2d2ebd818d | |||
| 9c43e55aec | |||
| aac909d0ef | |||
| 8351823bf6 | |||
| 41ca058bd7 | |||
| 4a4b1d89e3 | |||
| b527791a10 |
@@ -15,4 +15,3 @@ ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
SESSION_SECRET=change-me-session-secret
|
||||
API_PORT=8000
|
||||
MCP_PORT=8001
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.6
|
||||
|
||||
- Возвращён еженедельный автоматический запуск обхода сотрудников.
|
||||
|
||||
## 0.7.5
|
||||
|
||||
- Production Compose запускает только API и PostgreSQL.
|
||||
|
||||
## 0.7.4
|
||||
|
||||
- Ускорена фильтрация сотрудников по учёной степени.
|
||||
|
||||
## 0.7.3
|
||||
|
||||
- Восстановлена проверка профилей сотрудников и хранение истории URL профиля.
|
||||
|
||||
## 0.7.2
|
||||
|
||||
- В каталоге сотрудников добавлены фильтр и колонка учёной степени.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- Подключение к приложению: `app/main.py`
|
||||
- HTTP endpoint: `POST /mcp`
|
||||
- Локально при обычном запуске API: `http://localhost:8000/mcp`
|
||||
- В Docker Compose через отдельный сервис `mcp`: `http://localhost:8001/mcp`
|
||||
- В Docker Compose endpoint обслуживает `api`: `http://localhost:8000/mcp`
|
||||
- Авторизация на уровне приложения: отсутствует. Заголовок `Authorization` не проверяется и не влияет на ответ.
|
||||
|
||||
Если доступ к MCP нужно ограничить, это должно делаться внешним контуром: bind на localhost, VPN, firewall, reverse proxy или отдельная сетевая политика.
|
||||
@@ -622,7 +622,7 @@ Hash набора считается по отсортированному сп
|
||||
Список tools:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/mcp \
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
```
|
||||
@@ -630,7 +630,7 @@ curl http://localhost:8001/mcp \
|
||||
Поиск сотрудника:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/mcp \
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_employees","arguments":{"query":"Сергеев","limit":5}}}'
|
||||
```
|
||||
@@ -638,7 +638,7 @@ curl http://localhost:8001/mcp \
|
||||
Полная синхронизация:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/mcp \
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sync_employees","arguments":{"include_data":false}}}'
|
||||
```
|
||||
@@ -646,7 +646,7 @@ curl http://localhost:8001/mcp \
|
||||
Delta-синхронизация:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/mcp \
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"sync_employees","arguments":{"client_hash":"known-sha256","include_data":true}}}'
|
||||
```
|
||||
|
||||
22
README.md
22
README.md
@@ -4,9 +4,8 @@
|
||||
|
||||
## Архитектура
|
||||
|
||||
- `api`: FastAPI, REST API, HTML-админка, healthcheck.
|
||||
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
|
||||
- `mcp`: открытый HTTP MCP endpoint для ИИ-агентов.
|
||||
- `api`: FastAPI, REST API, HTML-админка, MCP endpoint и healthcheck.
|
||||
- `worker`: weekly scheduler, который запускает парсинг по `CRAWL_CRON`.
|
||||
- `postgres`: основная БД.
|
||||
|
||||
Парсер использует фиксированный источник сотрудников, по умолчанию `https://miem.hse.ru/persons`. Для каждой карточки сохраняются ФИО, должности, год начала работы, контакты, идентификаторы, вкладки профиля, секции, публикации, курсы, ВКР, новости, JSON-снапшот и сжатый HTML-снапшот. Детальные публикации дополнительно нормализуются в отдельную таблицу `employee_publications`, а новости из блока «В новостях» — в `employee_news_links`. Ссылки обходятся только из меню профиля самого сотрудника (`person-menu`), например `#sci`, `#teaching`, `#main`.
|
||||
@@ -22,8 +21,8 @@ cp .env.example .env
|
||||
Основные настройки:
|
||||
|
||||
- `DATABASE_URL`: строка подключения SQLAlchemy.
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
@@ -51,13 +50,12 @@ uvicorn app.main:app --reload
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
docker compose up -d --build --remove-orphans
|
||||
```
|
||||
|
||||
По умолчанию:
|
||||
|
||||
- API и админка: `http://localhost:8000`
|
||||
- MCP: `http://localhost:8001/mcp`
|
||||
- Postgres: `localhost:5432`
|
||||
|
||||
Таблицы создаются приложением при старте. При обновлении существующей базы приложение также добавляет недостающие runtime-колонки, например `crawl_runs.skipped_count`. SQL-миграции для ручного применения лежат в `migrations/`.
|
||||
@@ -84,7 +82,7 @@ docker compose up --build
|
||||
|
||||
## Парсинг
|
||||
|
||||
Weekly worker запускается по `CRAWL_CRON`. Ручной запуск доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
|
||||
@@ -131,7 +129,7 @@ Endpoint: `POST /mcp`, без авторизации на уровне прил
|
||||
Пример локального запроса списка tools:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/mcp \
|
||||
curl http://localhost:8000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
```
|
||||
@@ -141,10 +139,10 @@ curl http://localhost:8001/mcp \
|
||||
## Обслуживание
|
||||
|
||||
```bash
|
||||
docker compose logs -f api
|
||||
docker compose logs -f worker
|
||||
docker compose logs -f api
|
||||
docker compose logs -f worker
|
||||
docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Версия сервиса: `0.7.1`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.7.6`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
24
app/db.py
24
app/db.py
@@ -41,6 +41,30 @@ def _ensure_runtime_schema() -> None:
|
||||
models.EmployeeNewsLink.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "employees" in table_names and "employee_profile_urls" not in table_names:
|
||||
models.EmployeeProfileUrl.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
if "employees" in table_names:
|
||||
employee_columns = {column["name"] for column in inspector.get_columns("employees")}
|
||||
missing_columns = []
|
||||
if "profile_unavailable_streak" not in employee_columns:
|
||||
missing_columns.append("profile_unavailable_streak INTEGER NOT NULL DEFAULT 0")
|
||||
if "last_profile_check_at" not in employee_columns:
|
||||
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:
|
||||
with engine.begin() as connection:
|
||||
for column in missing_columns:
|
||||
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:
|
||||
return
|
||||
crawl_run_columns = {column["name"] for column in inspector.get_columns("crawl_runs")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
@@ -21,6 +21,7 @@ class Employee(Base):
|
||||
UniqueConstraint("profile_key", name="uq_employees_profile_key"),
|
||||
Index("ix_employees_full_name", "full_name"),
|
||||
Index("ix_employees_status", "status"),
|
||||
Index("ix_employees_has_academic_degree", "has_academic_degree"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -33,19 +34,39 @@ class Employee(Base):
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
profile_unavailable_streak: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_profile_check_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
parser_version: Mapped[str | None] = mapped_column(String(32))
|
||||
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))
|
||||
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)
|
||||
|
||||
snapshots: Mapped[list["EmployeeSnapshot"]] = relationship(back_populates="employee")
|
||||
profile_urls: Mapped[list["EmployeeProfileUrl"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
tabs: Mapped[list["ProfileTab"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
publications: Mapped[list["EmployeePublication"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
news_links: Mapped[list["EmployeeNewsLink"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
crawl_run_changes: Mapped[list["CrawlRunEmployeeChange"]] = relationship(back_populates="employee")
|
||||
|
||||
|
||||
class EmployeeProfileUrl(Base):
|
||||
__tablename__ = "employee_profile_urls"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "url", name="uq_employee_profile_urls_employee_url"),
|
||||
Index("ix_employee_profile_urls_employee_id", "employee_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
employee: Mapped[Employee] = relationship(back_populates="profile_urls")
|
||||
|
||||
|
||||
class EmployeeSnapshot(Base):
|
||||
__tablename__ = "employee_snapshots"
|
||||
__table_args__ = (Index("ix_employee_snapshots_employee_id", "employee_id"),)
|
||||
|
||||
23
app/services/academic_degrees.py
Normal file
23
app/services/academic_degrees.py
Normal 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
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime, time
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
@@ -10,6 +9,7 @@ from sqlalchemy import Select, Text, and_, desc, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import CrawlError, CrawlRun, CrawlRunEmployeeChange, Employee, EmployeeNewsLink
|
||||
from app.services.academic_degrees import academic_degrees
|
||||
|
||||
EMPLOYEE_SORTS = {
|
||||
"full_name": Employee.full_name,
|
||||
@@ -20,9 +20,6 @@ EMPLOYEE_SORTS = {
|
||||
"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]:
|
||||
data = _as_dict(employee.current_data)
|
||||
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"))
|
||||
emails = _clean_list(contacts.get("emails"))
|
||||
phones = _clean_list(contacts.get("phones"))
|
||||
academic_degrees = _academic_degrees(sections)
|
||||
degree_values = academic_degrees(data)
|
||||
return {
|
||||
"id": employee.id,
|
||||
"full_name": employee.full_name,
|
||||
@@ -46,7 +43,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
||||
"phones": phones,
|
||||
"phone_text": ", ".join(phones),
|
||||
"address": contacts.get("address"),
|
||||
"academic_degree_text": "; ".join(academic_degrees),
|
||||
"academic_degree_text": "; ".join(degree_values),
|
||||
"publications_count": _count_section_items(sections, "publications"),
|
||||
"courses_count": _count_section_items(sections, "courses_by_year"),
|
||||
"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:
|
||||
filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%")))
|
||||
if has_academic_degree is not None:
|
||||
data_text = Employee.current_data.cast(Text)
|
||||
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))
|
||||
filters.append(Employee.has_academic_degree.is_(has_academic_degree))
|
||||
if filters:
|
||||
stmt = stmt.where(and_(*filters))
|
||||
return stmt
|
||||
@@ -162,6 +152,10 @@ def stats_payload(db: Session) -> dict[str, Any]:
|
||||
return {
|
||||
"total": db.scalar(select(func.count()).select_from(Employee)) or 0,
|
||||
"active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0,
|
||||
"verification_required": db.scalar(
|
||||
select(func.count()).select_from(Employee).where(Employee.status == "verification_required")
|
||||
)
|
||||
or 0,
|
||||
"dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0,
|
||||
"new_in_last_run": latest_run.new_count if latest_run else 0,
|
||||
"latest_added": employee_display_payload(latest_added) if latest_added else None,
|
||||
@@ -205,7 +199,7 @@ def run_detail_payload(db: Session, run: CrawlRun | None) -> dict[str, Any] | No
|
||||
.order_by(CrawlRunEmployeeChange.created_at, CrawlRunEmployeeChange.id)
|
||||
).all()
|
||||
errors = db.scalars(select(CrawlError).where(CrawlError.crawl_run_id == run.id).order_by(CrawlError.created_at)).all()
|
||||
grouped_changes = {"new": [], "missing_from_source": [], "dismissed": []}
|
||||
grouped_changes = {"new": [], "missing_from_source": [], "verification_required": [], "dismissed": []}
|
||||
for change in changes:
|
||||
grouped_changes.setdefault(change.change_type, []).append(_change_payload(change))
|
||||
return {
|
||||
@@ -231,38 +225,8 @@ def format_admin_datetime(value: Any) -> str:
|
||||
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:
|
||||
labels = {"active": "Работает", "dismissed": "Уволен"}
|
||||
labels = {"active": "Работает", "verification_required": "Требует проверки", "dismissed": "Уволен"}
|
||||
return labels.get(status or "", status or "Не указано")
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ from app.models import (
|
||||
)
|
||||
from app.parser.collector import collect_profile_links
|
||||
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.resource_cache import ResourceCache
|
||||
|
||||
@@ -264,8 +265,9 @@ def _upsert_employee(db: Session, run: CrawlRun, parsed: dict) -> tuple[Employee
|
||||
employee.dismissed_at = None
|
||||
employee.profile_unavailable_streak = 0
|
||||
employee.last_profile_check_at = now
|
||||
employee.parser_version = parser_version
|
||||
if changed:
|
||||
employee.parser_version = parser_version
|
||||
employee.has_academic_degree = bool(academic_degrees(parsed))
|
||||
if changed:
|
||||
employee.current_data = parsed
|
||||
employee.current_checksum = checksum
|
||||
db.flush()
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<select class="directory__input" name="status">
|
||||
<option value="" {% if not filters.status %}selected{% endif %}>Все статусы</option>
|
||||
<option value="active" {% if filters.status == "active" %}selected{% endif %}>Работает</option>
|
||||
<option value="verification_required" {% if filters.status == "verification_required" %}selected{% endif %}>Требует проверки</option>
|
||||
<option value="dismissed" {% if filters.status == "dismissed" %}selected{% endif %}>Уволен</option>
|
||||
</select>
|
||||
<select class="directory__input" name="has_email">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% for group, title in [("new", "Новые сотрудники"), ("missing_from_source", "Потеряшки"), ("dismissed", "Уволенные")] %}
|
||||
{% for group, title in [("new", "Новые сотрудники"), ("missing_from_source", "Потеряшки"), ("verification_required", "Требуют проверки"), ("dismissed", "Уволенные")] %}
|
||||
<section class="panel"{% if group == "new" %} id="new-employees"{% endif %}>
|
||||
<h2 class="panel__title">{{ title }}</h2>
|
||||
{% set items = run.changes[group] %}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.7.2"
|
||||
FRONTEND_VERSION = "0.7.2"
|
||||
BACKEND_VERSION = "0.7.2"
|
||||
APP_VERSION = "0.7.6"
|
||||
FRONTEND_VERSION = "0.7.6"
|
||||
BACKEND_VERSION = "0.7.6"
|
||||
|
||||
@@ -35,17 +35,5 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-miem}:${POSTGRES_PASSWORD:-miem_password}@postgres:5432/${POSTGRES_DB:-miem_workers}
|
||||
ports:
|
||||
- "127.0.0.1:${MCP_PORT:-8001}:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS employees (
|
||||
dismissed_at TIMESTAMPTZ,
|
||||
parser_version VARCHAR(32),
|
||||
current_data JSONB,
|
||||
has_academic_degree BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
current_checksum VARCHAR(64),
|
||||
created_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_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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
17
migrations/008_profile_verification.sql
Normal file
17
migrations/008_profile_verification.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE employees
|
||||
ADD COLUMN IF NOT EXISTS profile_unavailable_streak INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE employees
|
||||
ADD COLUMN IF NOT EXISTS last_profile_check_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employee_profile_urls (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_employee_profile_urls_employee_url UNIQUE (employee_id, url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_profile_urls_employee_id
|
||||
ON employee_profile_urls (employee_id);
|
||||
11
migrations/009_academic_degree_filter.sql
Normal file
11
migrations/009_academic_degree_filter.sql
Normal 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);
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.7.2"
|
||||
version = "0.7.6"
|
||||
description = "MIEM employees parser, admin API, and MCP server"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -61,6 +61,7 @@ def test_list_employees_page_filters_and_displays_academic_degrees(db_session):
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
has_academic_degree=True,
|
||||
current_data={
|
||||
"sections": [
|
||||
{
|
||||
@@ -77,6 +78,7 @@ def test_list_employees_page_filters_and_displays_academic_degrees(db_session):
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
has_academic_degree=False,
|
||||
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]["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):
|
||||
employee = Employee(
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_health_returns_versions():
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.7.1"
|
||||
assert response.json()["backend_version"] == "0.7.3"
|
||||
|
||||
|
||||
def test_mcp_lists_tools_without_auth_and_ignores_auth_header():
|
||||
@@ -154,7 +154,7 @@ def test_mcp_service_info_returns_tools_and_dataset_hash():
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.json()["result"]["content"][0]["text"])
|
||||
assert payload["service_name"] == "miem-employees"
|
||||
assert payload["backend_version"] == "0.7.1"
|
||||
assert payload["backend_version"] == "0.7.3"
|
||||
assert payload["dataset"]["hash"]
|
||||
assert any(tool["name"] == "sync_employees" for tool in payload["tools"])
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ def test_upsert_employee_increments_new_count_and_records_change_for_new_employe
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
_upsert_employee(
|
||||
employee, _ = _upsert_employee(
|
||||
db_session,
|
||||
run,
|
||||
{
|
||||
@@ -282,14 +282,20 @@ def test_upsert_employee_increments_new_count_and_records_change_for_new_employe
|
||||
"profile_id": "newperson",
|
||||
"full_name": "New Person",
|
||||
"tabs": [],
|
||||
"sections": [],
|
||||
"sections": [
|
||||
{
|
||||
"title": "Образование и учёные степени",
|
||||
"year_entries": [{"text": "Кандидат технических наук"}],
|
||||
}
|
||||
],
|
||||
"parser_version": "0.2.0",
|
||||
"_html": "<html></html>",
|
||||
},
|
||||
)
|
||||
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()
|
||||
assert change.change_type == "new"
|
||||
assert change.full_name == "New Person"
|
||||
|
||||
@@ -113,3 +113,32 @@ def test_runtime_schema_creates_employee_news_links_table_when_employees_exist(m
|
||||
assert "employee_news_links" in inspector.get_table_names()
|
||||
columns = {column["name"] for column in inspector.get_columns("employee_news_links")}
|
||||
assert {"employee_id", "title", "url", "summary", "published_at", "published_year", "source_hash", "raw_data"}.issubset(columns)
|
||||
|
||||
|
||||
def test_runtime_schema_adds_profile_verification_fields(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE employees (
|
||||
id INTEGER PRIMARY KEY,
|
||||
profile_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
canonical_url TEXT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
|
||||
_ensure_runtime_schema()
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = {column["name"] for column in inspector.get_columns("employees")}
|
||||
assert {"profile_unavailable_streak", "last_profile_check_at", "has_academic_degree"}.issubset(columns)
|
||||
assert "employee_profile_urls" in inspector.get_table_names()
|
||||
|
||||
Reference in New Issue
Block a user