from __future__ import annotations import json import math import re import urllib.request import struct from collections import Counter from pathlib import Path from typing import Any import yaml ROOT = Path(__file__).resolve().parents[1] MODEL_CARDS_DIR = ROOT / "registry" / "model-cards" TOKEN_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9_]+", re.UNICODE) RUSSIAN_ENDINGS = ( "иями", "ями", "ами", "ого", "ему", "ыми", "ими", "ой", "ей", "ых", "их", "ую", "юю", "ая", "яя", "ое", "ее", "ом", "ем", "ам", "ям", "ах", "ях", "ы", "и", "а", "я", "е", "у", "ю", ) TOKEN_ALIASES = { "1с": ["1c", "bsl", "конфигурация"], "1c": ["1с", "bsl", "configuration"], "бсл": ["bsl", "1с"], "bsl": ["бсл", "1с"], "справочник": ["catalog", "справочники"], "справочники": ["справочник", "catalog"], "документ": ["documents", "документы"], "документы": ["документ", "documents"], "регистр": ["register", "регистры"], "регистры": ["регистр", "register"], "реквизит": ["attribute", "реквизиты"], "реквизиты": ["реквизит", "attribute"], "табличная": ["табличные", "часть"], "табличные": ["табличная", "часть"], "запрос": ["query", "read", "select", "выбрать"], "выбрать": ["запрос", "query", "select"], "форма": ["forms", "управляемая"], "модуль": ["module", "bsl"], "метаданные": ["metadata", "схема", "snapshot"], "схема": ["metadata", "метаданные"], "номенклатура": ["справочник", "catalog"], } def read_json(path: Path) -> dict: with path.open("r", encoding="utf-8-sig") as handle: data = json.load(handle) if not isinstance(data, dict): raise ValueError(f"{path} must contain a JSON object") return data def read_jsonl(path: Path) -> list[dict]: if not path.exists(): return [] records: list[dict] = [] with path.open("r", encoding="utf-8") as handle: for line_number, line in enumerate(handle, start=1): line = line.strip() if not line: continue try: record = json.loads(line) except json.JSONDecodeError as exc: raise ValueError(f"{path}:{line_number}: invalid JSONL: {exc}") from exc if not isinstance(record, dict): raise ValueError(f"{path}:{line_number}: record must be an object") records.append(record) return records def write_json(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def read_yaml_mapping(path: Path) -> dict[str, Any]: with path.open("r", encoding="utf-8") as handle: data = yaml.safe_load(handle) if not isinstance(data, dict): raise ValueError(f"{path} must contain a YAML mapping") return data def iter_model_card_paths(*, include_examples: bool = False) -> list[Path]: paths = sorted(MODEL_CARDS_DIR.rglob("*.yaml")) + sorted(MODEL_CARDS_DIR.rglob("*.yml")) if include_examples: return paths return [path for path in paths if "examples" not in path.parts] def load_model_card(card_id: str) -> dict[str, Any]: for suffix in (".yaml", ".yml"): path = MODEL_CARDS_DIR / f"{card_id}{suffix}" if path.exists(): return read_yaml_mapping(path) raise FileNotFoundError(f"Model card not found for id `{card_id}` in {MODEL_CARDS_DIR}") def localize_workspace_path(path: str) -> Path: if path.startswith("/workspace/"): if Path("/workspace").exists(): return Path(path) return ROOT / path.removeprefix("/workspace/") if path.startswith("/models/"): if Path("/models").exists(): return Path(path) return ROOT / "models" / path.removeprefix("/models/") return Path(path) def stem_russian_token(token: str) -> str: if not re.search(r"[а-яё]", token, flags=re.IGNORECASE) or len(token) < 6: return token for ending in RUSSIAN_ENDINGS: if token.endswith(ending) and len(token) - len(ending) >= 4: return token[: -len(ending)] return token def normalize_token(token: str) -> str: token = token.lower().replace("ё", "е") return stem_russian_token(token) def tokenize(text: str, *, expand_aliases: bool = False) -> list[str]: tokens: list[str] = [] for raw_token in TOKEN_RE.findall(text): token = normalize_token(raw_token) tokens.append(token) if expand_aliases: tokens.extend(normalize_token(alias) for alias in TOKEN_ALIASES.get(token, [])) return tokens def corpus_content_hash(records: list[dict]) -> str: import hashlib hasher = hashlib.sha256() for record in records: stable = { "id": record.get("id"), "document_id": record.get("document_id"), "source_path": record.get("source_path"), "chunk_index": record.get("chunk_index"), "title": record.get("title"), "content": record.get("content"), "metadata": record.get("metadata") or {}, } hasher.update(json.dumps(stable, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")) hasher.update(b"\n") return hasher.hexdigest() def hashing_embedding(text: str, *, dimensions: int = 384) -> list[float]: import hashlib if dimensions < 8: raise ValueError("dimensions must be >= 8") vector = [0.0] * dimensions tokens = tokenize(text, expand_aliases=True) if not tokens: return vector counts = Counter(tokens) for token, count in counts.items(): digest = hashlib.blake2b(token.encode("utf-8"), digest_size=8).digest() bucket = int.from_bytes(digest[:4], "little") % dimensions sign = 1.0 if digest[4] & 1 else -1.0 vector[bucket] += sign * (1.0 + math.log(float(count))) norm = math.sqrt(sum(value * value for value in vector)) if norm <= 0: return vector return [value / norm for value in vector] def pack_float_vector(vector: list[float]) -> bytes: return struct.pack(f"<{len(vector)}f", *vector) def unpack_float_vector(data: bytes, dimensions: int) -> list[float]: expected_size = dimensions * 4 if len(data) != expected_size: raise ValueError(f"Vector blob has {len(data)} bytes, expected {expected_size}") return list(struct.unpack(f"<{dimensions}f", data)) def cosine_similarity(left: list[float], right: list[float]) -> float: if not left or not right or len(left) != len(right): return 0.0 return float(sum(a * b for a, b in zip(left, right))) def build_lexical_index(records: list[dict]) -> dict: documents = [] document_frequency: Counter[str] = Counter() total_length = 0 for record in records: content = record.get("content") or "" tokens = tokenize(content) title_tokens = tokenize(str(record.get("title") or "")) term_frequency = Counter(tokens) document_frequency.update(term_frequency.keys()) total_length += len(tokens) documents.append( { "id": record.get("id"), "document_id": record.get("document_id"), "source_path": record.get("source_path"), "title": record.get("title"), "chunk_index": record.get("chunk_index"), "content": content, "metadata": record.get("metadata") or {}, "length": len(tokens), "title_tokens": title_tokens, "term_frequency": dict(term_frequency), } ) doc_count = len(documents) idf = { token: math.log((1 + doc_count) / (1 + frequency)) + 1 for token, frequency in document_frequency.items() } return { "schema_version": 2, "type": "lexical-bm25", "doc_count": doc_count, "avg_doc_length": round(total_length / doc_count, 4) if doc_count else 0, "idf": idf, "documents": documents, } def score_lexical_document(query_tf: Counter[str], document: dict, idf: dict, avg_doc_length: float) -> float: doc_tf = document.get("term_frequency") or {} doc_length = max(float(document.get("length") or 0), 1.0) avg_doc_length = max(float(avg_doc_length or doc_length), 1.0) title_tokens = set(document.get("title_tokens") or []) score = 0.0 k1 = 1.4 b = 0.72 for token, query_count in query_tf.items(): doc_count = doc_tf.get(token, 0) if doc_count: numerator = doc_count * (k1 + 1) denominator = doc_count + k1 * (1 - b + b * (doc_length / avg_doc_length)) score += query_count * float(idf.get(token, 1.0)) * (numerator / denominator) if token in title_tokens: score += 0.35 * query_count return score def search_lexical_index( index: dict, query: str, limit: int, *, candidate_limit: int | None = None, dedupe_by_document: bool = False, min_score: float = 0.0, source_types: list[str] | None = None, metadata_filters: dict[str, str] | None = None, ) -> list[dict]: query_tf = Counter(tokenize(query, expand_aliases=True)) if not query_tf: return [] results = [] idf = index.get("idf") or {} avg_doc_length = float(index.get("avg_doc_length") or 0) allowed_source_types = {source_type.lower() for source_type in source_types or []} exact_filters = {key: str(value).lower() for key, value in (metadata_filters or {}).items() if str(value).strip()} for document in index.get("documents") or []: metadata = document.get("metadata") or {} source_type = str(metadata.get("source_type") or "").lower() if allowed_source_types and source_type not in allowed_source_types: continue if exact_filters and any(str(metadata.get(key) or "").lower() != value for key, value in exact_filters.items()): continue score = score_lexical_document(query_tf, document, idf, avg_doc_length) if score > min_score: results.append({"score": score, "document": document}) results.sort(key=lambda item: item["score"], reverse=True) if candidate_limit: results = results[:candidate_limit] if dedupe_by_document: deduped = [] seen = set() for result in results: document_id = result["document"].get("document_id") or result["document"].get("source_path") if document_id in seen: continue seen.add(document_id) deduped.append(result) results = deduped return results[:limit] def call_chat_completion( base_url: str, model: str, messages: list[dict], *, temperature: float = 0.2, max_tokens: int = 1000, timeout: int = 180, ) -> str: url = f"{base_url.rstrip('/')}/v1/chat/completions" payload = { "model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } request = urllib.request.Request( url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request, timeout=timeout) as response: data = json.loads(response.read().decode("utf-8")) choices = data.get("choices") or [] if not choices: raise ValueError("chat response has no choices") message = choices[0].get("message") or {} content = (message.get("content") or "").strip() if not content: raise ValueError("chat response content is empty") return content