Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from build_1c_rag_index import main as _unused_lexical_main # noqa: F401,E402
|
||||
from build_1c_rag_vector_index import build_vector_index # noqa: E402
|
||||
from check_1c_rag_vector_freshness import check_vector_freshness # noqa: E402
|
||||
from common import build_lexical_index, write_json # noqa: E402
|
||||
from search_1c_rag_hybrid import hybrid_search # noqa: E402
|
||||
from search_1c_rag_vector import search_vector_index # noqa: E402
|
||||
|
||||
|
||||
class EmbeddingHandler(BaseHTTPRequestHandler):
|
||||
calls: list[dict] = []
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
length = int(self.headers.get("Content-Length") or "0")
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
EmbeddingHandler.calls.append({"path": self.path, "payload": payload, "authorization": self.headers.get("Authorization")})
|
||||
inputs = payload.get("input") if isinstance(payload.get("input"), list) else [payload.get("input")]
|
||||
data = []
|
||||
for index, text in enumerate(inputs):
|
||||
text_value = str(text or "").lower()
|
||||
if "номенклатура" in text_value or "реквизит" in text_value:
|
||||
vector = [1.0, 0.0, 0.0]
|
||||
elif "передзаписью" in text_value:
|
||||
vector = [0.0, 1.0, 0.0]
|
||||
else:
|
||||
vector = [0.0, 0.0, 1.0]
|
||||
data.append({"object": "embedding", "index": index, "embedding": vector})
|
||||
body = json.dumps({"object": "list", "data": data}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def run_embedding_server() -> tuple[ThreadingHTTPServer, str]:
|
||||
EmbeddingHandler.calls = []
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), EmbeddingHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, f"http://127.0.0.1:{server.server_port}"
|
||||
|
||||
|
||||
def write_jsonl(path: Path, records: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("".join(json.dumps(record, ensure_ascii=False) + "\n" for record in records), encoding="utf-8")
|
||||
|
||||
|
||||
def sample_records() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": "doc-bsl-0",
|
||||
"document_id": "doc-bsl",
|
||||
"source_path": "bsl.md",
|
||||
"title": "ПередЗаписью",
|
||||
"chunk_index": 0,
|
||||
"content": "Процедура ПередЗаписью проверяет заполнение Наименование и ставит Отказ.",
|
||||
"metadata": {"source_type": "bsl", "heading": "ПередЗаписью"},
|
||||
},
|
||||
{
|
||||
"id": "doc-meta-0",
|
||||
"document_id": "doc-meta",
|
||||
"source_path": "metadata.md",
|
||||
"title": "Реквизиты справочника",
|
||||
"chunk_index": 0,
|
||||
"content": "Справочник Номенклатура содержит реквизиты Артикул и ВидНоменклатуры.",
|
||||
"metadata": {"source_type": "metadata", "heading": "Реквизиты"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_build_and_search_vector_index(tmp_path: Path) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
index = tmp_path / "rag_vector_index.sqlite"
|
||||
write_jsonl(corpus, sample_records())
|
||||
|
||||
result = build_vector_index(corpus, index, dimensions=64, embedding_model="local-hashing-v1")
|
||||
search = search_vector_index(index, "реквизиты номенклатура", limit=2, source_types=["metadata"], corpus_path=corpus)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["doc_count"] == 2
|
||||
assert index.exists()
|
||||
assert search["status"] == "ok"
|
||||
assert search["freshness"]["status"] == "fresh"
|
||||
assert search["meta"]["embedding_model"] == "local-hashing-v1"
|
||||
assert search["results"]
|
||||
assert search["results"][0]["document"]["source_path"] == "metadata.md"
|
||||
|
||||
|
||||
def test_vector_index_reports_stale_corpus(tmp_path: Path) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
index = tmp_path / "rag_vector_index.sqlite"
|
||||
records = sample_records()
|
||||
write_jsonl(corpus, records)
|
||||
build_vector_index(corpus, index, dimensions=64, embedding_model="local-hashing-v1")
|
||||
records.append(
|
||||
{
|
||||
"id": "doc-query-0",
|
||||
"document_id": "doc-query",
|
||||
"source_path": "query.md",
|
||||
"title": "Запрос",
|
||||
"chunk_index": 0,
|
||||
"content": "ВЫБРАТЬ первые записи из регистра.",
|
||||
"metadata": {"source_type": "query"},
|
||||
}
|
||||
)
|
||||
write_jsonl(corpus, records)
|
||||
|
||||
search = search_vector_index(index, "запрос выбрать", limit=2, corpus_path=corpus)
|
||||
|
||||
assert search["freshness"]["status"] == "stale"
|
||||
|
||||
|
||||
def test_check_vector_freshness_reports_missing_and_fresh(tmp_path: Path) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
index = tmp_path / "rag_vector_index.sqlite"
|
||||
write_jsonl(corpus, sample_records())
|
||||
|
||||
missing = check_vector_freshness(index, corpus)
|
||||
build_vector_index(corpus, index, dimensions=64, embedding_model="local-hashing-v1")
|
||||
fresh = check_vector_freshness(index, corpus)
|
||||
|
||||
assert missing["status"] == "missing"
|
||||
assert fresh["status"] == "fresh"
|
||||
assert fresh["embedding_model"] == "local-hashing-v1"
|
||||
assert fresh["doc_count"] == 2
|
||||
|
||||
|
||||
def test_hybrid_search_combines_lexical_and_vector(tmp_path: Path) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
lexical_index = tmp_path / "rag_index.json"
|
||||
vector_index = tmp_path / "rag_vector_index.sqlite"
|
||||
records = sample_records()
|
||||
write_jsonl(corpus, records)
|
||||
write_json(lexical_index, build_lexical_index(records))
|
||||
build_vector_index(corpus, vector_index, dimensions=64, embedding_model="local-hashing-v1")
|
||||
|
||||
result = hybrid_search(
|
||||
"проверка перед записью",
|
||||
lexical_index_path=lexical_index,
|
||||
vector_index_path=vector_index,
|
||||
corpus_path=corpus,
|
||||
limit=2,
|
||||
candidate_limit=10,
|
||||
source_types=None,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["vector_freshness"]["status"] == "fresh"
|
||||
assert result["results"]
|
||||
assert "lexical" in result["results"][0]["channels"] or "vector" in result["results"][0]["channels"]
|
||||
|
||||
|
||||
def test_openai_compatible_embedding_provider_builds_and_searches(tmp_path: Path, monkeypatch) -> None:
|
||||
corpus = tmp_path / "rag_corpus.jsonl"
|
||||
index = tmp_path / "rag_vector_index.sqlite"
|
||||
write_jsonl(corpus, sample_records())
|
||||
server, base_url = run_embedding_server()
|
||||
monkeypatch.setenv("TEST_EMBEDDING_KEY", "secret-test-key")
|
||||
try:
|
||||
result = build_vector_index(
|
||||
corpus,
|
||||
index,
|
||||
dimensions=3,
|
||||
embedding_model="test-embedding-model",
|
||||
embedding_provider="openai-compatible",
|
||||
embedding_base_url=base_url,
|
||||
embedding_api_key_env="TEST_EMBEDDING_KEY",
|
||||
batch_size=2,
|
||||
)
|
||||
search = search_vector_index(
|
||||
index,
|
||||
"реквизиты номенклатура",
|
||||
limit=2,
|
||||
corpus_path=corpus,
|
||||
embedding_api_key_env="TEST_EMBEDDING_KEY",
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
assert result["embedding_provider"] == "openai-compatible"
|
||||
assert result["embedding_dimensions"] == 3
|
||||
assert search["status"] == "ok"
|
||||
assert search["meta"]["embedding_provider"] == "openai-compatible"
|
||||
assert search["meta"].get("embedding_api_key") is None
|
||||
assert search["results"][0]["document"]["source_path"] == "metadata.md"
|
||||
assert EmbeddingHandler.calls
|
||||
assert all(call["payload"]["model"] == "test-embedding-model" for call in EmbeddingHandler.calls)
|
||||
assert any(call["authorization"] == "Bearer secret-test-key" for call in EmbeddingHandler.calls)
|
||||
Reference in New Issue
Block a user