206 lines
7.5 KiB
Python
Executable File
206 lines
7.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Research Validator — проверка качества исследовательских отчётов.
|
|
|
|
Три проверки, которые ловят 80% брака от локальных LLM:
|
|
1. Цитаты — все [N] существуют, нет «мёртвых» ссылок
|
|
2. URL — все валидные (http, не битые relative)
|
|
3. Полнота — минимум источников, достаточно контента
|
|
|
|
Использование:
|
|
from research_validator import validate_report
|
|
|
|
ok, errors = validate_report(report_text, sources_list)
|
|
if not ok:
|
|
print("Проблемы:", errors)
|
|
# → отправить errors обратно в промпт LLM на исправление
|
|
|
|
Также используется для предварительной фильтрации URL из SearXNG:
|
|
clean_urls = filter_valid_urls(raw_results)
|
|
"""
|
|
|
|
import re
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
def validate_report(report: str, sources: list[dict]) -> tuple[bool, list[str]]:
|
|
"""Проверить отчёт на соответствие стандартам качества.
|
|
|
|
Args:
|
|
report: текст отчёта (Markdown)
|
|
sources: список словарей с ключами ref (int), title (str), url (str)
|
|
|
|
Returns:
|
|
(True, []) — всё хорошо
|
|
(False, ["ошибка 1", "ошибка 2"]) — нужно исправлять
|
|
"""
|
|
errors = []
|
|
|
|
# 1. Проверка цитат: все [N] в тексте должны существовать в sources
|
|
cited_nums = set()
|
|
for m in re.finditer(r'\[(\d+)\]', report):
|
|
cited_nums.add(int(m.group(1)))
|
|
|
|
source_nums = {s["ref"] for s in sources}
|
|
|
|
missing = cited_nums - source_nums
|
|
if missing:
|
|
errors.append(
|
|
f"Цитаты [{', '.join(str(n) for n in sorted(missing))}] "
|
|
f"не существуют — нет таких источников"
|
|
)
|
|
|
|
unused = source_nums - cited_nums
|
|
if unused:
|
|
errors.append(
|
|
f"Источники [{', '.join(str(n) for n in sorted(unused))}] "
|
|
f"не процитированы в тексте"
|
|
)
|
|
|
|
# 2. Проверка URL: все валидные
|
|
for s in sources:
|
|
url = s.get("url", "")
|
|
if not url:
|
|
errors.append(f"[{s['ref']}] пустой URL")
|
|
elif not url.startswith("http"):
|
|
errors.append(f"[{s['ref']}] невалидный URL: {url[:80]}")
|
|
else:
|
|
parsed = urlparse(url)
|
|
if not parsed.netloc:
|
|
errors.append(f"[{s['ref']}] URL без домена: {url[:80]}")
|
|
|
|
# 3. Проверка полноты
|
|
if len(sources) < 3:
|
|
errors.append(f"Слишком мало источников: {len(sources)} (минимум 3)")
|
|
|
|
total_text = sum(len(s.get("text", "")) for s in sources)
|
|
if total_text < 1000:
|
|
errors.append(f"Слишком мало контента: {total_text} символов (минимум 1000)")
|
|
|
|
# 4. Проверка структуры — есть ли ключевые секции
|
|
has_overview = bool(re.search(r'###?\s*(Обзор|Overview)', report))
|
|
has_sources = bool(re.search(r'###?\s*(Источники|Sources)', report))
|
|
has_citations = bool(re.search(r'\[\d+\]', report))
|
|
|
|
if not has_overview:
|
|
errors.append("Нет секции «Обзор»")
|
|
if not has_citations:
|
|
errors.append("Нет inline-цитат [N] в тексте")
|
|
if not has_sources and sources:
|
|
errors.append("Нет секции «Источники» в конце отчёта")
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
def filter_valid_urls(results: list[dict]) -> list[dict]:
|
|
"""Отфильтровать результаты SearXNG: только с валидными URL.
|
|
|
|
Args:
|
|
results: список словарей от SearXNG (url, title, content, engine)
|
|
|
|
Returns:
|
|
Отфильтрованный список (только http/https URL)
|
|
"""
|
|
valid = []
|
|
for r in results:
|
|
url = r.get("url", "")
|
|
if not url or not url.startswith("http"):
|
|
continue
|
|
parsed = urlparse(url)
|
|
if not parsed.netloc:
|
|
continue
|
|
# Отбрасываем явно битые
|
|
if any(x in url.lower() for x in ["example.com", "localhost", "0.0.0.0"]):
|
|
continue
|
|
valid.append(r)
|
|
return valid
|
|
|
|
|
|
def score_source(result: dict) -> float:
|
|
"""Оценить качество источника от 0 до 1.
|
|
|
|
Учитывает: поисковый движок, длину сниппета, наличие даты.
|
|
"""
|
|
score = 0.5 # базовый
|
|
|
|
engine = result.get("engine", "").lower()
|
|
engine_scores = {
|
|
"wikipedia": 0.4, # +0.4 за достоверность
|
|
"arxiv": 0.3,
|
|
"google": 0.2,
|
|
"bing": 0.1,
|
|
"duckduckgo": 0.1,
|
|
"brave": 0.1,
|
|
"reddit": -0.1, # штраф за субъективность
|
|
}
|
|
score += engine_scores.get(engine, 0)
|
|
|
|
# Бонус за длинный сниппет (больше информации)
|
|
snippet = result.get("content", "") or result.get("snippet", "")
|
|
if len(snippet) > 200:
|
|
score += 0.1
|
|
if len(snippet) > 500:
|
|
score += 0.1
|
|
|
|
# Штраф за отсутствие заголовка
|
|
if not result.get("title"):
|
|
score -= 0.3
|
|
|
|
return max(0.0, min(1.0, score))
|
|
|
|
|
|
def build_fix_prompt(report: str, errors: list[str], sources: list[dict]) -> str:
|
|
"""Собрать промпт для LLM с конкретными ошибками для исправления."""
|
|
sources_text = "\n".join(
|
|
f"[{s['ref']}] {s['title']} — {s['url']}" for s in sources
|
|
)
|
|
errors_text = "\n".join(f"- {e}" for e in errors)
|
|
|
|
return f"""Отчёт содержит ошибки. Исправь их и верни ПОЛНЫЙ исправленный отчёт.
|
|
|
|
ОШИБКИ:
|
|
{errors_text}
|
|
|
|
ДОСТУПНЫЕ ИСТОЧНИКИ:
|
|
{sources_text}
|
|
|
|
ТЕКУЩИЙ ОТЧЁТ:
|
|
{report[:4000]}
|
|
|
|
ТРЕБОВАНИЯ:
|
|
- Каждый факт с цитатой [N], где N — номер из списка источников
|
|
- Все указанные источники должны быть процитированы
|
|
- В конце — секция «### Источники» с полными URL
|
|
- Секция «### Обзор» обязательна
|
|
- Не добавляй источники, которых нет в списке"""
|
|
|
|
|
|
# ═══════════════════════════════════════════
|
|
# CLI
|
|
# ═══════════════════════════════════════════
|
|
|
|
if __name__ == "__main__":
|
|
import sys, json
|
|
|
|
if len(sys.argv) < 2:
|
|
print("Usage: research_validator.py <report.md> [sources.json]")
|
|
print(" sources.json: [{\"ref\": 1, \"title\": \"...\", \"url\": \"...\"}, ...]")
|
|
sys.exit(1)
|
|
|
|
report_text = open(sys.argv[1]).read()
|
|
|
|
if len(sys.argv) > 2:
|
|
sources = json.loads(open(sys.argv[2]).read())
|
|
else:
|
|
sources = []
|
|
|
|
ok, errors = validate_report(report_text, sources)
|
|
|
|
if ok:
|
|
print("✓ Отчёт валиден")
|
|
else:
|
|
print(f"✗ Найдено {len(errors)} проблем:")
|
|
for e in errors:
|
|
print(f" - {e}")
|
|
sys.exit(1)
|