"""Управление пробелами в знаниях (research gaps). Работает **только с PostgreSQL** — никакого веб-поиска. Поиск выполняет сама модель встроенным инструментом \`web_search\` (SearXNG и автоматический research-цикл убраны). Жизненный цикл пробела: open → researching → resolved | dismissed Поля: question — что нужно исследовать (формулировка) reason — почему пробел обнаружен priority — 0.0 .. 1.0 (больше = важнее) status — open | researching | resolved | dismissed """ from __future__ import annotations VALID_REASONS = ("low_coverage", "low_confidence", "stale", "contested", "user_query") VALID_STATUSES = ("open", "researching", "resolved", "dismissed") _COLS = "id, question, reason, priority, status, created_at" def list_gaps(conn, *, status: str | None = None, limit: int = 50) -> list[dict]: """Список пробелов. Без фильтра — сначала открытые, затем по приоритету.""" limit = max(1, min(int(limit), 200)) with conn.cursor() as cur: if status: if status not in VALID_STATUSES: raise ValueError(f"status must be one of {VALID_STATUSES}") cur.execute( f"SELECT {_COLS} FROM pm_research_gap WHERE status = %s " "ORDER BY priority DESC, id LIMIT %s", (status, limit), ) else: cur.execute( f"SELECT {_COLS} FROM pm_research_gap " "ORDER BY (status = 'open') DESC, priority DESC, id LIMIT %s", (limit,), ) return cur.fetchall() def get_gap(conn, gap_id: int) -> dict | None: """Один пробел по id (или None).""" with conn.cursor() as cur: cur.execute(f"SELECT {_COLS} FROM pm_research_gap WHERE id = %s", (gap_id,)) return cur.fetchone() def add_gap(conn, question: str, reason: str = "user_query", priority: float = 0.5) -> int: """Зарегистрировать пробел. Возвращает id.""" question = (question or "").strip() if not question: raise ValueError("question must not be empty") if reason not in VALID_REASONS: raise ValueError(f"reason must be one of {VALID_REASONS}") priority = max(0.0, min(1.0, float(priority))) with conn.cursor() as cur: cur.execute( "INSERT INTO pm_research_gap (question, reason, priority, status) " "VALUES (%s, %s, %s, 'open') RETURNING id", (question, reason, priority), ) row = cur.fetchone() conn.commit() return row["id"] def update_gap(conn, gap_id: int, *, question: str | None = None, reason: str | None = None, priority: float | None = None, status: str | None = None) -> bool: """Обновить поля пробела. Возвращает True, если запись найдена. Типичные переходы: status='researching' (взял в работу), status='resolved' (закрыт), status='dismissed' (не актуален). """ sets: list[str] = [] vals: list = [] if question is not None: question = question.strip() if not question: raise ValueError("question must not be empty") sets.append("question = %s"); vals.append(question) if reason is not None: if reason not in VALID_REASONS: raise ValueError(f"reason must be one of {VALID_REASONS}") sets.append("reason = %s"); vals.append(reason) if priority is not None: sets.append("priority = %s"); vals.append(max(0.0, min(1.0, float(priority)))) if status is not None: if status not in VALID_STATUSES: raise ValueError(f"status must be one of {VALID_STATUSES}") sets.append("status = %s"); vals.append(status) if not sets: return get_gap(conn, gap_id) is not None vals.append(gap_id) with conn.cursor() as cur: cur.execute(f"UPDATE pm_research_gap SET {', '.join(sets)} WHERE id = %s", vals) found = cur.rowcount > 0 conn.commit() return found def delete_gap(conn, gap_id: int) -> bool: """Удалить пробел. Возвращает True, если запись существовала.""" with conn.cursor() as cur: cur.execute("DELETE FROM pm_research_gap WHERE id = %s", (gap_id,)) found = cur.rowcount > 0 conn.commit() return found