From 4a5695ed9c4777be38267afd34ef49c332d39fcf Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Thu, 23 Apr 2026 09:59:24 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint1):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=20=D1=8D=D0=BC=D0=B1=D0=B5=D0=B4=D0=B4=D0=B8=D0=BD=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=20=D1=87=D0=B0=D0=BD=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Расширяем просмотр документа, чтобы оператор видел не только текстовые чанки, но и как они лежат в ChromaDB в виде векторов — по паттерну из work-pcs-dr-cdss. Backend: - services/vectorstore.get_document_chunks теперь запрашивает include=["embeddings"] и отдаёт вектор как list[float]. Chroma возвращает numpy-массивы, поэтому проверка наличия embeddings сделана через len(), без or-шортката. - models.ChunkDetail: поля embedding: list[float] + embedding_dim: int. - routers/documents прокидывает вектор и размерность в ответ. Frontend (static/index.html): - В карточку чанка добавлен блок .chunk-card-actions с кнопкой «вектор (N dim)»; раскрывается в .embedding-box с полным списком координат (округление до 6 знаков, моноширинный шрифт, скролл). - Функция toggleChunkText переписана через .closest + querySelector, чтобы не ломаться от новой обёртки кнопок. - Добавлена toggleEmb(embId). Проверено на загруженных документах — возвращается по 1024 координаты (E5-large), совпадает с ожиданиями embedding-модели. Co-Authored-By: Claude Opus 4.7 (1M context) --- models/responses.py | 2 ++ routers/documents.py | 2 ++ services/vectorstore.py | 8 ++++-- static/index.html | 55 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/models/responses.py b/models/responses.py index 619f668..5fc2f12 100644 --- a/models/responses.py +++ b/models/responses.py @@ -38,6 +38,8 @@ class ChunkDetail(BaseModel): page_number: int = 0 text: str = "" char_length: int = 0 + embedding: list[float] = Field(default_factory=list) + embedding_dim: int = 0 class DocumentChunksResponse(BaseModel): diff --git a/routers/documents.py b/routers/documents.py index ab3c229..d635738 100644 --- a/routers/documents.py +++ b/routers/documents.py @@ -128,6 +128,8 @@ async def get_document_chunks(document_id: str): page_number=c["metadata"].get("page_number", 0), text=c["text"], char_length=len(c["text"]), + embedding=c.get("embedding", []), + embedding_dim=len(c.get("embedding", [])), ) for c in raw_chunks ] diff --git a/services/vectorstore.py b/services/vectorstore.py index 33d7ea8..57bbcae 100644 --- a/services/vectorstore.py +++ b/services/vectorstore.py @@ -118,18 +118,22 @@ class VectorStoreService: return list(docs.values()) def get_document_chunks(self, document_id: str) -> list[dict]: - """Return all chunks for a document, sorted by chunk_index.""" + """Return all chunks for a document, sorted by chunk_index. Includes embeddings.""" results = self.collection.get( where={"document_id": document_id}, - include=["documents", "metadatas"], + include=["documents", "metadatas", "embeddings"], ) items = [] if results["ids"]: + embeddings = results.get("embeddings") + has_emb = embeddings is not None and len(embeddings) > 0 for i, chunk_id in enumerate(results["ids"]): + emb = embeddings[i] if has_emb and i < len(embeddings) else None items.append({ "chunk_id": chunk_id, "text": results["documents"][i], "metadata": results["metadatas"][i], + "embedding": [float(x) for x in emb] if emb is not None else [], }) items.sort(key=lambda x: x["metadata"].get("chunk_index", 0)) return items diff --git a/static/index.html b/static/index.html index efbb491..6d5d7d8 100644 --- a/static/index.html +++ b/static/index.html @@ -151,6 +151,35 @@ background: none; padding: 4px 0 0 0; } + .chunk-card-actions { + display: flex; + gap: 14px; + align-items: center; + margin-top: 4px; + } + .embedding-box { + margin-top: 8px; + padding: 8px 10px; + background: #f0f4ff; + border: 1px solid #c7d2fe; + border-radius: 6px; + display: none; + } + .embedding-box.open { display: block; } + .emb-header { + font-size: 11px; + color: var(--muted); + margin-bottom: 4px; + } + .emb-values { + font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; + font-size: 10.5px; + line-height: 1.4; + max-height: 140px; + overflow-y: auto; + word-break: break-all; + color: #3730a3; + } .empty { padding: 20px; color: var(--muted); @@ -435,10 +464,15 @@ async function toggleChunks(id) { body.innerHTML = '
чанков нет
'; return; } - body.innerHTML = d.chunks.map(c => { + body.innerHTML = d.chunks.map((c, i) => { const long = c.text.length > 300; const short = long ? c.text.slice(0, 300) + "…" : c.text; const safeFull = esc(c.text).replace(/"/g, """); + const embId = `emb-${id}-${c.index}-${i}`; + const dim = c.embedding_dim || (c.embedding ? c.embedding.length : 0); + const embValues = (c.embedding && c.embedding.length) + ? "[" + c.embedding.map(v => v.toFixed(6)).join(", ") + "]" + : "—"; return `
@@ -448,7 +482,16 @@ async function toggleChunks(id) { ${c.char_length.toLocaleString("ru")} симв.
${esc(short)}
- ${long ? '' : ""} +
+ ${long ? '' : ""} + ${dim ? `` : ""} +
+ ${dim ? ` +
+
эмбеддинг · ${dim} компонент · cosine space
+
${embValues}
+
+ ` : ""}
`; }).join(""); @@ -458,13 +501,19 @@ async function toggleChunks(id) { } function toggleChunkText(btn) { - const textEl = btn.previousElementSibling; + const card = btn.closest(".chunk-card"); + const textEl = card.querySelector(".chunk-card-text"); const expanded = textEl.getAttribute("data-expanded") === "1"; textEl.textContent = expanded ? textEl.getAttribute("data-short") : textEl.getAttribute("data-full"); textEl.setAttribute("data-expanded", expanded ? "0" : "1"); btn.textContent = expanded ? "показать полностью" : "свернуть"; } +function toggleEmb(embId) { + const box = document.getElementById(embId); + if (box) box.classList.toggle("open"); +} + async function deleteDoc(id, name) { if (!confirm(`Удалить документ «${name}»?`)) return; try {