Compare commits
11 Commits
fix/plain-
...
feature/ta
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d393d38bb | |||
| 2c43c7c9ad | |||
| 14e86ccdd8 | |||
| 47d51cd36b | |||
| c2537cf445 | |||
| dffe072ce2 | |||
| 06c275cc40 | |||
| 92051184f5 | |||
| c048f20734 | |||
| af6b0b8098 | |||
| 94ba04ff56 |
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
## Не выпущено
|
## Не выпущено
|
||||||
|
|
||||||
|
- Backend обновлён до `0.10.3`: запрос о налоге на прибыль поднимает действующий
|
||||||
|
Налоговый кодекс; точное совпадение фразы дополнительно усиливает результат.
|
||||||
|
- Backend обновлён до `0.10.2`: действующие документы ранжируются выше, а
|
||||||
|
утратившие силу — после остальных; из лаборатории удалён пустой блок фрагмента.
|
||||||
|
- Backend обновлён до `0.10.1`: поисковая выдача по регистрации больше не
|
||||||
|
ограничивается только тремя заранее выбранными нормативными актами.
|
||||||
|
- Backend обновлён до `0.10.0`: поисковый API использует общий планировщик
|
||||||
|
запросов, расширяет аббревиатуры ОсОО/ООО/ЖЧК и для запроса о регистрации
|
||||||
|
приоритизирует положение КР 2026 года, закон об обществах и ГК КР.
|
||||||
- Backend обновлён до `0.9.4`: из поисковых фрагментов убраны технические маркеры
|
- Backend обновлён до `0.9.4`: из поисковых фрагментов убраны технические маркеры
|
||||||
подсветки OpenSearch.
|
подсветки OpenSearch.
|
||||||
- Backend обновлён до `0.9.3`: исправлен локальный индекс production Search API,
|
- Backend обновлён до `0.9.3`: исправлен локальный индекс production Search API,
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ Telegram-бот — только часть рабочего окружения
|
|||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
|
||||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||||
`0.9.4`. В поисковых фрагментах скрыты технические маркеры подсветки OpenSearch.
|
`0.10.3`. Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||||
|
действующий Налоговый кодекс для запросов о налоге на прибыль.
|
||||||
|
|
||||||
| Компонент | Версия | Состояние |
|
| Компонент | Версия | Состояние |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||||
| Backend | `0.9.4` | в поисковых фрагментах скрыты технические маркеры подсветки OpenSearch |
|
| Backend | `0.10.3` | приоритет кодекса для темы налога на прибыль; тематический поиск ОсОО/ЖЧК |
|
||||||
| Frontend | — | ещё не создан |
|
| Frontend | — | ещё не создан |
|
||||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||||
| RAG и база знаний | — | ещё не созданы |
|
| RAG и база знаний | — | ещё не созданы |
|
||||||
@@ -58,4 +59,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.4 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.3 · Frontend — не создан
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend Акылдаш
|
# Backend Акылдаш
|
||||||
|
|
||||||
Версия: `0.9.4`
|
Версия: `0.10.3`
|
||||||
|
|
||||||
Первая backend-область проекта — загрузка правовых документов из официального
|
Первая backend-область проекта — загрузка правовых документов из официального
|
||||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||||
@@ -240,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Backend v0.9.4 · Frontend — не создан
|
Акылдаш · Backend v0.10.3 · Frontend — не создан
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
from search.minjust_opensearch import APP_VERSION, request_json
|
from search.minjust_opensearch import APP_VERSION, request_json
|
||||||
from search.catalog import CATALOGS, labels
|
from search.catalog import CATALOGS, labels
|
||||||
|
from search.query import build_search_query
|
||||||
from search.reviews import ReviewSnapshots, ReviewStore
|
from search.reviews import ReviewSnapshots, ReviewStore
|
||||||
|
|
||||||
|
|
||||||
API_VERSION = "v1"
|
API_VERSION = "v1"
|
||||||
SEARCH_ALGORITHM_VERSION = "search-1"
|
SEARCH_ALGORITHM_VERSION = "search-5"
|
||||||
LANGUAGES = {"ru", "ky"}
|
LANGUAGES = {"ru", "ky"}
|
||||||
CODE = re.compile(r"^[0-9]+$")
|
CODE = re.compile(r"^[0-9]+$")
|
||||||
MAX_PAGE_SIZE = 100
|
MAX_PAGE_SIZE = 100
|
||||||
@@ -139,12 +140,18 @@ class Api:
|
|||||||
"from": (page - 1) * page_size,
|
"from": (page - 1) * page_size,
|
||||||
"size": page_size + 1,
|
"size": page_size + 1,
|
||||||
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "legal_force_code", "date_adopted", "number"],
|
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "legal_force_code", "date_adopted", "number"],
|
||||||
"query": {"bool": {"filter": filters, "must": {"multi_match": {"query": text, "fields": [f"document_name_{language}", f"text_{language}"], "type": "cross_fields"}}}},
|
"query": {"bool": {"filter": filters, "must": build_search_query(language, text)}},
|
||||||
"collapse": {"field": "document_code"},
|
"collapse": {"field": "document_code"},
|
||||||
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
|
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
|
||||||
}
|
}
|
||||||
|
body["sort"] = [{"_script": {
|
||||||
|
"type": "number",
|
||||||
|
"order": "asc",
|
||||||
|
"script": {"lang": "painless", "source": "doc['status_code'].size() == 0 ? 1 : (doc['status_code'].value == 'active' ? 0 : (doc['status_code'].value == 'repealed' ? 2 : 1))"},
|
||||||
|
}}]
|
||||||
if sort == "date":
|
if sort == "date":
|
||||||
body["sort"] = [{"date_adopted": "desc"}, {"_score": "desc"}]
|
body["sort"].append({"date_adopted": "desc"})
|
||||||
|
body["sort"].append({"_score": "desc"})
|
||||||
response = self.query_opensearch(body)
|
response = self.query_opensearch(body)
|
||||||
try:
|
try:
|
||||||
hits = response["hits"]["hits"]
|
hits = response["hits"]["hits"]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
|||||||
|
|
||||||
from search.catalog import authority_codes, legal_force_code, source_code
|
from search.catalog import authority_codes, legal_force_code, source_code
|
||||||
|
|
||||||
APP_VERSION = "0.9.4"
|
APP_VERSION = "0.10.3"
|
||||||
LANGUAGES = {"ru", "ky"}
|
LANGUAGES = {"ru", "ky"}
|
||||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||||
|
|
||||||
|
|||||||
@@ -10,23 +10,40 @@ import urllib.parse
|
|||||||
from search.minjust_opensearch import APP_VERSION, request_json
|
from search.minjust_opensearch import APP_VERSION, request_json
|
||||||
|
|
||||||
|
|
||||||
def company_registration_clauses(language: str, query: str) -> list[dict]:
|
LEGAL_FORM_ALIASES = {
|
||||||
patterns = {
|
"ru": {
|
||||||
"ru": (r"\bкак\s+откры\w*\s+осоо\b", r"\b(порядок|процедура)\s+откры\w*\s+осоо\b", r"\bкак\s+зарегистр\w*\s+осоо\b"),
|
"осоо": ("ооо", "общество с ограниченной ответственностью"),
|
||||||
"ky": (r"\bжчк\s+ач\w*\s+тартиби\b", r"\bжчк\s+кантип\s+ач\w*\b"),
|
"ооо": ("осоо", "общество с ограниченной ответственностью"),
|
||||||
}[language]
|
"общество с ограниченной ответственностью": ("осоо", "ооо"),
|
||||||
if not any(re.search(pattern, query.casefold()) for pattern in patterns):
|
"общества с ограниченной ответственностью": ("осоо", "ооо"),
|
||||||
return []
|
},
|
||||||
|
"ky": {
|
||||||
|
"жчк": ("жоопкерчилиги чектелген коом",),
|
||||||
|
"жоопкерчилиги чектелген коом": ("жчк",),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000))
|
||||||
|
TAX_PROFIT_DOCUMENTS = (("112340", 3000),)
|
||||||
|
|
||||||
|
|
||||||
|
def query_variants(language: str, query: str) -> list[str]:
|
||||||
|
variants = [query]
|
||||||
|
for alias, expansions in LEGAL_FORM_ALIASES[language].items():
|
||||||
|
pattern = re.compile(rf"(?<!\w){re.escape(alias)}(?!\w)", re.IGNORECASE)
|
||||||
|
if pattern.search(query):
|
||||||
|
variants.extend(pattern.sub(expansion, query) for expansion in expansions)
|
||||||
|
return list(dict.fromkeys(variants))
|
||||||
|
|
||||||
|
|
||||||
|
def document_priority_clauses(language: str, documents: tuple[tuple[str, int], ...]) -> list[dict]:
|
||||||
status = {"ru": "Действует", "ky": "Күчүндө"}[language]
|
status = {"ru": "Действует", "ky": "Күчүндө"}[language]
|
||||||
# ponytail: curated legal mapping; replace with a reviewed intent catalog when coverage expands.
|
return [
|
||||||
def clause(document_code: str, boost: int) -> dict:
|
{
|
||||||
return {
|
|
||||||
"constant_score": {
|
"constant_score": {
|
||||||
"filter": {
|
"filter": {
|
||||||
"bool": {
|
"bool": {
|
||||||
"filter": [
|
"filter": [
|
||||||
{"term": {"document_code": document_code}},
|
{"term": {"document_code": code}},
|
||||||
{"term": {f"status_{language}": status}},
|
{"term": {f"status_{language}": status}},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -34,29 +51,80 @@ def company_registration_clauses(language: str, query: str) -> list[dict]:
|
|||||||
"boost": boost,
|
"boost": boost,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for code, boost in documents
|
||||||
|
]
|
||||||
|
|
||||||
return [clause("230044970", 2000), clause("667", 1000)]
|
|
||||||
|
def company_registration_clauses(language: str, query: str) -> list[dict]:
|
||||||
|
non_registration_context = {
|
||||||
|
"ru": (r"\bсч[её]т\w*", r"\bбанк\w*", r"\bфилиал\w*", r"\bпредставительств\w*"),
|
||||||
|
"ky": (r"\bбанк\w*", r"\bэсеп\w*", r"\bфилиал\w*", r"\bөкүлчүлүк\w*"),
|
||||||
|
}[language]
|
||||||
|
normalized_query = query.casefold()
|
||||||
|
if any(re.search(pattern, normalized_query) for pattern in non_registration_context):
|
||||||
|
return []
|
||||||
|
|
||||||
|
forms, patterns = {
|
||||||
|
"ru": (
|
||||||
|
r"(?:осоо|ооо|обществ\w*\s+с\s+ограниченной\s+ответственностью)",
|
||||||
|
(
|
||||||
|
r"\bкак\s+(?:откры\w*|созда\w*|зарегистр\w*)\s+{form}\b",
|
||||||
|
r"\b(?:порядок|процедура)\s+(?:открыти\w*|создани\w*|регистраци\w*)\s+{form}\b",
|
||||||
|
r"\b(?:государственн\w+\s+)?регистраци\w*\s+{form}\b",
|
||||||
|
r"\bсоздани\w*\s+{form}\b",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"ky": (
|
||||||
|
r"(?:жчк|жоопкерчилиги\s+чектелген\s+коом)",
|
||||||
|
(
|
||||||
|
r"\bжчк\s+ач\w*\s+тартиби\b",
|
||||||
|
r"\bжчк\s+кантип\s+ач\w*\b",
|
||||||
|
r"\b{form}\s+(?:ачуу|ачуунун|каттоо|каттоонун)\b",
|
||||||
|
r"\b(?:ачуу|каттоо)\s+{form}\b",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}[language]
|
||||||
|
if not any(re.search(pattern.format(form=forms), normalized_query) for pattern in patterns):
|
||||||
|
return []
|
||||||
|
|
||||||
|
return document_priority_clauses(language, COMPANY_REGISTRATION_DOCUMENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def tax_profit_clauses(language: str, query: str) -> list[dict]:
|
||||||
|
if language != "ru" or not re.search(r"\bналог\w*\s+(?:на\s+)?прибыл\w*\b", query.casefold()):
|
||||||
|
return []
|
||||||
|
# ponytail: exact topic anchor; expand only with verified tax-code sources.
|
||||||
|
return document_priority_clauses(language, TAX_PROFIT_DOCUMENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def build_search_query(language: str, query: str) -> dict:
|
||||||
|
registration_clauses = company_registration_clauses(language, query)
|
||||||
|
intent_clauses = [*registration_clauses, *tax_profit_clauses(language, query)]
|
||||||
|
full_text = [
|
||||||
|
{
|
||||||
|
"multi_match": {
|
||||||
|
"query": variant,
|
||||||
|
"fields": [f"document_name_{language}^4", f"text_{language}"],
|
||||||
|
"type": "cross_fields",
|
||||||
|
"operator": "OR" if registration_clauses else "AND",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for variant in query_variants(language, query)
|
||||||
|
]
|
||||||
|
phrase_matches = [
|
||||||
|
{"match_phrase": {f"text_{language}": {"query": variant, "boost": 3}}}
|
||||||
|
for variant in query_variants(language, query)
|
||||||
|
]
|
||||||
|
planned = {"should": [*full_text, *phrase_matches, *intent_clauses], "minimum_should_match": 1}
|
||||||
|
return {"bool": planned}
|
||||||
|
|
||||||
|
|
||||||
def build_search_body(language: str, query: str, top_k: int) -> bytes:
|
def build_search_body(language: str, query: str, top_k: int) -> bytes:
|
||||||
full_text = {
|
|
||||||
"multi_match": {
|
|
||||||
"query": query,
|
|
||||||
"fields": [f"document_name_{language}", f"text_{language}"],
|
|
||||||
"type": "cross_fields",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clauses = company_registration_clauses(language, query)
|
|
||||||
bool_query = {"filter": {"term": {"language": language}}}
|
|
||||||
if clauses:
|
|
||||||
bool_query.update({"should": [full_text, *clauses], "minimum_should_match": 1})
|
|
||||||
else:
|
|
||||||
bool_query["must"] = full_text
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"size": top_k,
|
"size": top_k,
|
||||||
"track_total_hits": False,
|
"track_total_hits": False,
|
||||||
"_source": ["document_code"],
|
"_source": ["document_code"],
|
||||||
"query": {"bool": bool_query},
|
"query": {"bool": {"filter": [{"term": {"language": language}}, {"term": {"is_current_edition": True}}], "must": build_search_query(language, query)}},
|
||||||
"collapse": {"field": "document_code"},
|
"collapse": {"field": "document_code"},
|
||||||
}, ensure_ascii=False).encode()
|
}, ensure_ascii=False).encode()
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,7 @@
|
|||||||
.review__result-title { display: flex; gap: .65rem; align-items: baseline; width: 100%; text-align: start; font-weight: 800; border: 0; padding: 0; background: none; color: var(--brand-dark); }
|
.review__result-title { display: flex; gap: .65rem; align-items: baseline; width: 100%; text-align: start; font-weight: 800; border: 0; padding: 0; background: none; color: var(--brand-dark); }
|
||||||
.review__result-title:hover { background: none; border-color: transparent; color: var(--brand); }
|
.review__result-title:hover { background: none; border-color: transparent; color: var(--brand); }
|
||||||
.review__rank { flex: 0 0 auto; color: var(--brand); font-variant-numeric: tabular-nums; }
|
.review__rank { flex: 0 0 auto; color: var(--brand); font-variant-numeric: tabular-nums; }
|
||||||
.review__meta, .review__snippet { margin-block: .5rem; color: var(--muted); }
|
.review__meta { margin-block: .5rem; color: var(--muted); font-size: .8rem; }
|
||||||
.review__meta { font-size: .8rem; }
|
|
||||||
.review__snippet { overflow-wrap: anywhere; }
|
|
||||||
.review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; margin-block: 1rem .75rem; padding: 0; border: 0; }
|
.review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; margin-block: 1rem .75rem; padding: 0; border: 0; }
|
||||||
.review__rating legend { width: 100%; margin-block-end: .35rem; font-size: .8rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; }
|
.review__rating legend { width: 100%; margin-block-end: .35rem; font-size: .8rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; }
|
||||||
.review__rating label { min-width: 0; padding: .45rem .25rem; border: 1px solid #c5d0d8; border-radius: .5rem; color: var(--muted); text-align: center; font-size: .8rem; cursor: pointer; }
|
.review__rating label { min-width: 0; padding: .45rem .25rem; border: 1px solid #c5d0d8; border-radius: .5rem; color: var(--muted); text-align: center; font-size: .8rem; cursor: pointer; }
|
||||||
@@ -119,7 +117,7 @@
|
|||||||
<div class="review__actions"><div class="review__actions-inner"><label>Ваше имя или псевдоним<input id="reviewer" maxlength="120" autocomplete="name" placeholder="Например, Айжан К."></label> <label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label> <button class="review__button--primary" id="save" type="button">Сохранить разметку</button></div></div>
|
<div class="review__actions"><div class="review__actions-inner"><label>Ваше имя или псевдоним<input id="reviewer" maxlength="120" autocomplete="name" placeholder="Например, Айжан К."></label> <label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label> <button class="review__button--primary" id="save" type="button">Сохранить разметку</button></div></div>
|
||||||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||||||
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
|
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
|
||||||
<footer class="review__header">Акылдаш · Backend v0.9.4 · Внутренняя лаборатория релевантности</footer>
|
<footer class="review__header">Акылдаш · Backend v0.10.3 · Внутренняя лаборатория релевантности</footer>
|
||||||
<script>
|
<script>
|
||||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||||
const TOUR_KEY = 'akyldash-search-review-tour-seen';
|
const TOUR_KEY = 'akyldash-search-review-tour-seen';
|
||||||
@@ -146,11 +144,10 @@
|
|||||||
const item = document.createElement('li'); item.className = 'review__result'; item.dataset.rank = index + 1; item.dataset.code = result.code;
|
const item = document.createElement('li'); item.className = 'review__result'; item.dataset.rank = index + 1; item.dataset.code = result.code;
|
||||||
const title = document.createElement('button'); title.type = 'button'; title.className = 'review__result-title'; title.innerHTML = `<span class="review__rank">#${index + 1}</span><span></span>`; title.lastElementChild.textContent = escapeText(result.name || 'Название не указано'); title.addEventListener('click', () => openDocument(index, title));
|
const title = document.createElement('button'); title.type = 'button'; title.className = 'review__result-title'; title.innerHTML = `<span class="review__rank">#${index + 1}</span><span></span>`; title.lastElementChild.textContent = escapeText(result.name || 'Название не указано'); title.addEventListener('click', () => openDocument(index, title));
|
||||||
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
|
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
|
||||||
const snippet = document.createElement('div'); snippet.className = 'review__snippet'; snippet.textContent = result.snippet || 'Фрагмент не найден.';
|
|
||||||
const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`;
|
const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`;
|
||||||
[['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value} — ${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); });
|
[['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value} — ${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); });
|
||||||
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
|
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
|
||||||
item.append(title, meta, snippet, rating, comment); $('results').append(item);
|
item.append(title, meta, rating, comment); $('results').append(item);
|
||||||
});
|
});
|
||||||
applyDraft();
|
applyDraft();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,44 @@ class SearchApiTest(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ApiError, "within 10000 results"):
|
with self.assertRaisesRegex(ApiError, "within 10000 results"):
|
||||||
api.handle("GET", "/search?q=x&page=100&page_size=100")
|
api.handle("GET", "/search?q=x&page=100&page_size=100")
|
||||||
|
|
||||||
|
def test_production_api_uses_registration_intent_boosts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
api = self.make_api(Path(temporary))
|
||||||
|
response = {"hits": {"hits": []}}
|
||||||
|
with patch("search.api.request_json", return_value=response) as request:
|
||||||
|
api.handle("GET", "/search?q=%D0%BA%D0%B0%D0%BA+%D0%BE%D1%82%D0%BA%D1%80%D1%8B%D1%82%D1%8C+%D0%9E%D1%81%D0%9E%D0%9E&language=ru")
|
||||||
|
body = json.loads(request.call_args.args[2])
|
||||||
|
planned = body["query"]["bool"]["must"]["bool"]
|
||||||
|
self.assertEqual(planned["minimum_should_match"], 1)
|
||||||
|
self.assertNotIn("filter", planned)
|
||||||
|
self.assertTrue(any("constant_score" in clause for clause in planned["should"]))
|
||||||
|
self.assertTrue(any("общество с ограниченной ответственностью" in clause.get("multi_match", {}).get("query", "") for clause in planned["should"]))
|
||||||
|
|
||||||
|
def test_production_api_boosts_tax_code_for_profit_tax_query(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
api = self.make_api(Path(temporary))
|
||||||
|
with patch("search.api.request_json", return_value={"hits": {"hits": []}}) as request:
|
||||||
|
api.handle("GET", "/search?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C&language=ru")
|
||||||
|
body = json.loads(request.call_args.args[2])
|
||||||
|
planned = body["query"]["bool"]["must"]["bool"]
|
||||||
|
anchors = [clause["constant_score"]["filter"]["bool"]["filter"][0] for clause in planned["should"] if "constant_score" in clause]
|
||||||
|
self.assertIn({"term": {"document_code": "112340"}}, anchors)
|
||||||
|
|
||||||
|
def test_search_sorts_repealed_documents_after_other_statuses(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
api = self.make_api(Path(temporary))
|
||||||
|
with patch("search.api.request_json", return_value={"hits": {"hits": []}}) as request:
|
||||||
|
api.handle("GET", "/search?q=НДС+на+импорт&language=ru")
|
||||||
|
body = json.loads(request.call_args.args[2])
|
||||||
|
source = body["sort"][0]["_script"]["script"]["source"]
|
||||||
|
self.assertIn("'active' ? 0", source)
|
||||||
|
self.assertIn("'repealed' ? 2", source)
|
||||||
|
self.assertEqual(body["sort"][1], {"_score": "desc"})
|
||||||
|
|
||||||
|
api.handle("GET", "/search?q=НДС+на+импорт&language=ru&sort=date")
|
||||||
|
body = json.loads(request.call_args.args[2])
|
||||||
|
self.assertEqual(body["sort"][1], {"date_adopted": "desc"})
|
||||||
|
|
||||||
def test_document_editions_openapi_and_validation(self):
|
def test_document_editions_openapi_and_validation(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
api = self.make_api(Path(temporary))
|
api = self.make_api(Path(temporary))
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from search.evaluate_relevance import evaluate, load_queries
|
from search.evaluate_relevance import evaluate, load_queries
|
||||||
from search.query import build_search_body
|
from search.query import COMPANY_REGISTRATION_DOCUMENTS, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
|
||||||
|
|
||||||
|
|
||||||
class SearchRelevanceTest(unittest.TestCase):
|
class SearchRelevanceTest(unittest.TestCase):
|
||||||
@@ -37,7 +37,17 @@ class SearchRelevanceTest(unittest.TestCase):
|
|||||||
body = json.loads(build_search_body("ru", "трудовой договор", 10))
|
body = json.loads(build_search_body("ru", "трудовой договор", 10))
|
||||||
self.assertFalse(body["track_total_hits"])
|
self.assertFalse(body["track_total_hits"])
|
||||||
self.assertEqual(body["collapse"], {"field": "document_code"})
|
self.assertEqual(body["collapse"], {"field": "document_code"})
|
||||||
self.assertEqual(body["query"]["bool"]["must"]["multi_match"]["type"], "cross_fields")
|
self.assertIn({"term": {"is_current_edition": True}}, body["query"]["bool"]["filter"])
|
||||||
|
lexical_query = body["query"]["bool"]["must"]["bool"]["should"][0]["multi_match"]
|
||||||
|
self.assertEqual(lexical_query["type"], "cross_fields")
|
||||||
|
self.assertEqual(lexical_query["fields"], ["document_name_ru^4", "text_ru"])
|
||||||
|
self.assertEqual(lexical_query["operator"], "AND")
|
||||||
|
self.assertTrue(any("match_phrase" in clause for clause in body["query"]["bool"]["must"]["bool"]["should"]))
|
||||||
|
|
||||||
|
def test_legal_form_abbreviations_expand_in_both_directions(self):
|
||||||
|
self.assertIn("как открыть общество с ограниченной ответственностью", query_variants("ru", "как открыть ОсОО"))
|
||||||
|
self.assertIn("регистрация осоо", [variant.casefold() for variant in query_variants("ru", "регистрация общества с ограниченной ответственностью")])
|
||||||
|
self.assertIn("жчк ачуу тартиби", [variant.casefold() for variant in query_variants("ky", "жоопкерчилиги чектелген коом ачуу тартиби")])
|
||||||
|
|
||||||
def test_company_registration_intent_boosts_current_documents(self):
|
def test_company_registration_intent_boosts_current_documents(self):
|
||||||
for language, query, status in (
|
for language, query, status in (
|
||||||
@@ -45,21 +55,40 @@ class SearchRelevanceTest(unittest.TestCase):
|
|||||||
("ky", "ЖЧК ачуу тартиби", "Күчүндө"),
|
("ky", "ЖЧК ачуу тартиби", "Күчүндө"),
|
||||||
):
|
):
|
||||||
body = json.loads(build_search_body(language, query, 10))
|
body = json.loads(build_search_body(language, query, 10))
|
||||||
search_query = body["query"]["bool"]
|
search_query = body["query"]["bool"]["must"]["bool"]
|
||||||
self.assertEqual(search_query["minimum_should_match"], 1)
|
self.assertEqual(search_query["minimum_should_match"], 1)
|
||||||
boosts = [clause["constant_score"] for clause in search_query["should"][1:]]
|
self.assertNotIn("filter", search_query)
|
||||||
self.assertEqual([item["boost"] for item in boosts], [2000, 1000])
|
lexical = [clause["multi_match"] for clause in search_query["should"] if "multi_match" in clause]
|
||||||
|
self.assertTrue(all(clause["type"] == "cross_fields" and clause["operator"] == "OR" for clause in lexical))
|
||||||
|
boosts = [clause["constant_score"] for clause in search_query["should"] if "constant_score" in clause]
|
||||||
|
self.assertEqual([item["boost"] for item in boosts], [boost for _, boost in COMPANY_REGISTRATION_DOCUMENTS])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts],
|
[item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts],
|
||||||
["230044970", "667"],
|
[code for code, _ in COMPANY_REGISTRATION_DOCUMENTS],
|
||||||
)
|
)
|
||||||
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {f"status_{language}": status}} for item in boosts))
|
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {f"status_{language}": status}} for item in boosts))
|
||||||
|
|
||||||
|
def test_profit_tax_intent_prioritizes_the_current_tax_code(self):
|
||||||
|
for query in ("налог на прибыль", "налога на прибыль организаций", "налогообложение прибыли"):
|
||||||
|
body = json.loads(build_search_body("ru", query, 10))
|
||||||
|
clauses = body["query"]["bool"]["must"]["bool"]["should"]
|
||||||
|
boosts = [clause["constant_score"] for clause in clauses if "constant_score" in clause]
|
||||||
|
self.assertEqual([item["boost"] for item in boosts], [boost for _, boost in TAX_PROFIT_DOCUMENTS])
|
||||||
|
self.assertEqual([item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts], ["112340"])
|
||||||
|
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {"status_ru": "Действует"}} for item in boosts))
|
||||||
|
|
||||||
|
for query in ("прибыль организации", "налог на доходы", "налог на имущество"):
|
||||||
|
body = json.loads(build_search_body("ru", query, 10))
|
||||||
|
clauses = body["query"]["bool"]["must"]["bool"]["should"]
|
||||||
|
self.assertFalse(any("constant_score" in clause for clause in clauses))
|
||||||
|
|
||||||
def test_company_registration_intent_ignores_non_procedural_queries(self):
|
def test_company_registration_intent_ignores_non_procedural_queries(self):
|
||||||
for language, query in (
|
for language, query in (
|
||||||
("ru", "ОсОО зарегистрирован?"),
|
("ru", "ОсОО зарегистрирован?"),
|
||||||
("ru", "кто зарегистрировал ОсОО"),
|
("ru", "кто зарегистрировал ОсОО"),
|
||||||
("ru", "как открыть счет ОсОО"),
|
("ru", "как открыть счет ОсОО"),
|
||||||
|
("ru", "как открыть ОсОО банковский счет"),
|
||||||
|
("ru", "как открыть ОсОО банковский счёт"),
|
||||||
("ru", "как открыть филиал ОсОО"),
|
("ru", "как открыть филиал ОсОО"),
|
||||||
("ru", "как создать договор для ОсОО"),
|
("ru", "как создать договор для ОсОО"),
|
||||||
("ru", "порядок создания логотипа ОсОО"),
|
("ru", "порядок создания логотипа ОсОО"),
|
||||||
@@ -69,7 +98,8 @@ class SearchRelevanceTest(unittest.TestCase):
|
|||||||
("ky", "ЖЧК кантип логотип түзүү"),
|
("ky", "ЖЧК кантип логотип түзүү"),
|
||||||
):
|
):
|
||||||
body = json.loads(build_search_body(language, query, 10))
|
body = json.loads(build_search_body(language, query, 10))
|
||||||
self.assertNotIn("should", body["query"]["bool"])
|
clauses = body["query"]["bool"]["must"]["bool"]["should"]
|
||||||
|
self.assertFalse(any("constant_score" in clause for clause in clauses))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -39,4 +39,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.4 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.3 · Frontend — не создан
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Статус проекта
|
# Статус проекта
|
||||||
|
|
||||||
Последняя проверка: 2026-09-14
|
Последняя проверка: 2026-09-16
|
||||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||||
|
|
||||||
- Telegram-бот: `0.2.2`
|
- Telegram-бот: `0.2.2`
|
||||||
- Telegram-бот на Synology: `0.2.1`
|
- Telegram-бот на Synology: `0.2.1`
|
||||||
- Backend: `0.9.4`
|
- Backend: `0.10.3`
|
||||||
- Frontend: не создан
|
- Frontend: не создан
|
||||||
|
|
||||||
## Краткий итог
|
## Краткий итог
|
||||||
@@ -17,6 +17,29 @@
|
|||||||
оценки юристов и зафиксировать baseline. ЦБД Минюста служит источником для
|
оценки юристов и зафиксировать baseline. ЦБД Минюста служит источником для
|
||||||
проверки документов и редакций, а не системой для сравнения поисковой выдачи.
|
проверки документов и редакций, а не системой для сравнения поисковой выдачи.
|
||||||
|
|
||||||
|
### Точка остановки на 2026-09-16
|
||||||
|
|
||||||
|
- В работе ветка `feature/tax-profit-search-intent` (коммиты `14e86cc` и
|
||||||
|
`2c43c7c`): тематический запрос «налог на прибыль» поднимает действующий НК
|
||||||
|
КР; для обычных запросов сохранён `cross_fields`, добавлено усиление точной
|
||||||
|
фразы. Нужна дальнейшая проверка на контрольном наборе запросов.
|
||||||
|
- Независимое ревью полного diff завершено: блокирующих замечаний нет.
|
||||||
|
Backend-тесты: 33 пройдены, 1 пропущен; `git diff --check` чистый.
|
||||||
|
- PR для ветки пока не создан. `gitea-api` не может разрешить
|
||||||
|
`git.vakanaut.ru` из WSL. Пользователь сообщил, что сайт открывается в
|
||||||
|
Windows-браузере; в WSL не разрешается также `example.com`. Последняя
|
||||||
|
проверенная настройка WSL DNS: `nameserver 10.255.255.254` в
|
||||||
|
`/etc/resolv.conf`. Несколько часов назад, по словам пользователя, Gitea API
|
||||||
|
работал. DNS не меняли, WSL не перезапускали.
|
||||||
|
- Текущий локальный backend-контейнер при последней проверке был `0.10.2`;
|
||||||
|
изменения ветки (`0.10.3`) не развёрнуты. Не деплоить без отдельного запроса.
|
||||||
|
- В рабочем дереве есть постороннее пользовательское изменение `.gitignore`;
|
||||||
|
не включать его в коммиты и не удалять.
|
||||||
|
- Следующий шаг: после восстановления DNS повторить `gitea-api`, проверить
|
||||||
|
наличие PR и создать его только если PR для ветки ещё нет; затем продолжить
|
||||||
|
ревью/проверки. Сначала диагностировать кратковременный сбой DNS в WSL, не
|
||||||
|
менять сетевые настройки и не перезапускать WSL без учёта возможного простоя.
|
||||||
|
|
||||||
## Уже сделано
|
## Уже сделано
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
@@ -262,4 +285,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.4 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.3 · Frontend — не создан
|
||||||
|
|||||||
Reference in New Issue
Block a user