feat(sprint2.5): чистка чанков и переиндексация
Чанкер тащил в базу markdown-мусор: навигационные блоки «Вернуться на:»
со списками ссылок, инлайн-ссылки [текст](url) в теле, служебные
пометки _Источник: .../file.md_, лишние пустые строки. Всё это ело
контекст LLM и засоряло правую панель отладки.
- services/text_cleanup: clean_markdown_text — удаляет навигационные
строки, строки-только-ссылки (обычно это меню), служебные _Источник:_,
раскрывает инлайн-ссылки [x](url) → x, сжимает 3+ переносов до 2.
- services/document_processor: process_document теперь возвращает
(id, raw_text, sections, chunks); чистку применяем к заголовкам и
телам секций; чанки короче 20 символов выбрасываем с пересчётом
индексов. Вспомогательная rechunk_raw_text — для переиндексации.
Чтобы переиндексировать без повторной загрузки файла, нужен исходный
текст. Вводим отдельный слой:
- новая таблица SQLite documents (id, name, file_type, raw_text,
created_at, updated_at) + миграция Alembic 7ee7296ccd6d.
- db/models/Document + регистрация в db.models.__init__.
- services/document_service: save/get/list/delete для raw_text.
- routers/documents.upload: сохраняет raw_text в SQLite перед
индексацией в Chroma; delete убирает и из SQLite, и из Chroma.
- Новые эндпоинты POST /documents/{id}/reindex и
POST /documents/reindex-all — берут raw_text из SQLite, пропускают
через rechunk_raw_text, заменяют чанки в Chroma.
Существующие 4 документа были перезалиты вручную (решение: не делать
одноразовый backfill, проще залить заново). Старая Chroma очищена,
новые чанки прошли через чистку — мусор ушёл.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
|
from db.models.document import Document
|
||||||
from db.models.message import Message
|
from db.models.message import Message
|
||||||
from db.models.thread import Thread
|
from db.models.thread import Thread
|
||||||
|
|
||||||
__all__ = ["Thread", "Message"]
|
__all__ = ["Thread", "Message", "Document"]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Document(Base):
|
||||||
|
"""Исходный текст документа — для переиндексации с новыми правилами чанкера."""
|
||||||
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True) # UUID из process_document
|
||||||
|
name: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
file_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
raw_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""add documents table for raw_text
|
||||||
|
|
||||||
|
Revision ID: 7ee7296ccd6d
|
||||||
|
Revises: e7199587be4b
|
||||||
|
Create Date: 2026-04-23 10:56:33.955742
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7ee7296ccd6d'
|
||||||
|
down_revision: Union[str, None] = 'e7199587be4b'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('documents',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('file_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('raw_text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('documents')
|
||||||
|
# ### end Alembic commands ###
|
||||||
+105
-4
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from db.session import get_session
|
||||||
from models.responses import (
|
from models.responses import (
|
||||||
ChunkDetail,
|
ChunkDetail,
|
||||||
ChunkPreview,
|
ChunkPreview,
|
||||||
@@ -12,7 +14,8 @@ from models.responses import (
|
|||||||
DocumentListResponse,
|
DocumentListResponse,
|
||||||
DocumentUploadResponse,
|
DocumentUploadResponse,
|
||||||
)
|
)
|
||||||
from services.document_processor import process_document
|
from services import document_service
|
||||||
|
from services.document_processor import process_document, rechunk_raw_text
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
|||||||
async def upload_document(
|
async def upload_document(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
document_name: str | None = Form(None),
|
document_name: str | None = Form(None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
from main import vectorstore_service
|
from main import vectorstore_service
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@ async def upload_document(
|
|||||||
|
|
||||||
display_name = document_name or filename
|
display_name = document_name or filename
|
||||||
try:
|
try:
|
||||||
document_id, sections, chunks = process_document(file_bytes, filename)
|
document_id, raw_text, sections, chunks = process_document(file_bytes, filename)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -59,6 +63,13 @@ async def upload_document(
|
|||||||
raise HTTPException(status_code=400, detail="No content could be extracted from the document")
|
raise HTTPException(status_code=400, detail="No content could be extracted from the document")
|
||||||
|
|
||||||
file_type = ext.lstrip(".")
|
file_type = ext.lstrip(".")
|
||||||
|
await document_service.save_document_raw(
|
||||||
|
session=session,
|
||||||
|
document_id=document_id,
|
||||||
|
name=display_name,
|
||||||
|
file_type=file_type,
|
||||||
|
raw_text=raw_text,
|
||||||
|
)
|
||||||
chunks_count = vectorstore_service.add_document(
|
chunks_count = vectorstore_service.add_document(
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
document_name=display_name,
|
document_name=display_name,
|
||||||
@@ -144,14 +155,104 @@ async def get_document_chunks(document_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{document_id}", response_model=DocumentDeleteResponse)
|
@router.delete("/{document_id}", response_model=DocumentDeleteResponse)
|
||||||
async def delete_document(document_id: str):
|
async def delete_document(document_id: str, session: AsyncSession = Depends(get_session)):
|
||||||
from main import vectorstore_service
|
from main import vectorstore_service
|
||||||
|
|
||||||
if vectorstore_service is None:
|
if vectorstore_service is None:
|
||||||
raise HTTPException(status_code=503, detail="Service not ready")
|
raise HTTPException(status_code=503, detail="Service not ready")
|
||||||
|
|
||||||
deleted = vectorstore_service.delete_document(document_id)
|
deleted = vectorstore_service.delete_document(document_id)
|
||||||
|
await document_service.delete_document_raw(session, document_id)
|
||||||
|
|
||||||
if deleted == 0:
|
if deleted == 0:
|
||||||
raise HTTPException(status_code=404, detail="Document not found")
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
return DocumentDeleteResponse(ok=True, deleted_chunks=deleted)
|
return DocumentDeleteResponse(ok=True, deleted_chunks=deleted)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{document_id}/reindex", response_model=DocumentUploadResponse)
|
||||||
|
async def reindex_document(document_id: str, session: AsyncSession = Depends(get_session)):
|
||||||
|
"""Переразметить документ с актуальными правилами чанкера на основе сохранённого raw_text."""
|
||||||
|
from main import vectorstore_service
|
||||||
|
|
||||||
|
if vectorstore_service is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Service not ready")
|
||||||
|
|
||||||
|
doc = await document_service.get_document_raw(session, document_id)
|
||||||
|
if doc is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Document raw_text not found — reindex невозможен")
|
||||||
|
|
||||||
|
chunks = rechunk_raw_text(doc.raw_text)
|
||||||
|
if not chunks:
|
||||||
|
raise HTTPException(status_code=400, detail="После переразметки не осталось чанков")
|
||||||
|
|
||||||
|
vectorstore_service.delete_document(document_id)
|
||||||
|
chunks_count = vectorstore_service.add_document(
|
||||||
|
document_id=document_id,
|
||||||
|
document_name=doc.name,
|
||||||
|
file_type=doc.file_type,
|
||||||
|
chunks=[
|
||||||
|
{
|
||||||
|
"text": c.text,
|
||||||
|
"section": c.section,
|
||||||
|
"page_number": c.page_number,
|
||||||
|
"chunk_index": c.chunk_index,
|
||||||
|
}
|
||||||
|
for c in chunks
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks_prev = [
|
||||||
|
ChunkPreview(
|
||||||
|
index=c.chunk_index,
|
||||||
|
section=c.section,
|
||||||
|
page_number=c.page_number,
|
||||||
|
text_preview=c.text[:300],
|
||||||
|
char_length=len(c.text),
|
||||||
|
)
|
||||||
|
for c in chunks[:3]
|
||||||
|
]
|
||||||
|
|
||||||
|
return DocumentUploadResponse(
|
||||||
|
document_id=document_id,
|
||||||
|
name=doc.name,
|
||||||
|
chunks_count=chunks_count,
|
||||||
|
status="reindexed",
|
||||||
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
chunks_preview=chunks_prev,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reindex-all")
|
||||||
|
async def reindex_all(session: AsyncSession = Depends(get_session)):
|
||||||
|
"""Переразметить все документы, у которых есть raw_text в SQLite."""
|
||||||
|
from main import vectorstore_service
|
||||||
|
|
||||||
|
if vectorstore_service is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Service not ready")
|
||||||
|
|
||||||
|
docs = await document_service.list_documents_raw(session)
|
||||||
|
results = []
|
||||||
|
for doc in docs:
|
||||||
|
chunks = rechunk_raw_text(doc.raw_text)
|
||||||
|
if not chunks:
|
||||||
|
results.append({"document_id": doc.id, "name": doc.name, "status": "empty"})
|
||||||
|
continue
|
||||||
|
vectorstore_service.delete_document(doc.id)
|
||||||
|
n = vectorstore_service.add_document(
|
||||||
|
document_id=doc.id,
|
||||||
|
document_name=doc.name,
|
||||||
|
file_type=doc.file_type,
|
||||||
|
chunks=[
|
||||||
|
{
|
||||||
|
"text": c.text,
|
||||||
|
"section": c.section,
|
||||||
|
"page_number": c.page_number,
|
||||||
|
"chunk_index": c.chunk_index,
|
||||||
|
}
|
||||||
|
for c in chunks
|
||||||
|
],
|
||||||
|
)
|
||||||
|
results.append({"document_id": doc.id, "name": doc.name, "status": "reindexed", "chunks_count": n})
|
||||||
|
|
||||||
|
return {"total": len(results), "results": results}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import fitz # pymupdf
|
|||||||
from docx import Document as DocxDocument
|
from docx import Document as DocxDocument
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from services.text_cleanup import clean_markdown_text
|
||||||
|
|
||||||
|
MIN_CHUNK_TEXT_LENGTH = 20 # чанки короче — выбрасываем (обычно это хвосты после чистки)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -276,25 +279,74 @@ def chunk_sections(
|
|||||||
# --- Main processor ---
|
# --- Main processor ---
|
||||||
|
|
||||||
|
|
||||||
def process_document(file_bytes: bytes, filename: str) -> tuple[str, list[ParsedSection], list[Chunk]]:
|
def _sections_to_markdown(sections: list[ParsedSection]) -> str:
|
||||||
|
"""Собрать секции в markdown-подобный текст — используется как raw_text для PDF/DOCX,
|
||||||
|
чтобы при переиндексации можно было снова пропустить через parse_text."""
|
||||||
|
parts = []
|
||||||
|
for s in sections:
|
||||||
|
if s.heading:
|
||||||
|
parts.append(f"{'#' * max(1, s.heading_level)} {s.heading}")
|
||||||
|
if s.body:
|
||||||
|
parts.append(s.body)
|
||||||
|
return "\n\n".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def process_document(
|
||||||
|
file_bytes: bytes, filename: str
|
||||||
|
) -> tuple[str, str, list[ParsedSection], list[Chunk]]:
|
||||||
|
"""Парсит документ, чистит markdown-мусор, режет на чанки.
|
||||||
|
|
||||||
|
Returns: (document_id, raw_text, sections, chunks)
|
||||||
|
raw_text — очищенный текст, пригодный для переиндексации с новыми правилами.
|
||||||
|
"""
|
||||||
document_id = str(uuid.uuid4())
|
document_id = str(uuid.uuid4())
|
||||||
ext = Path(filename).suffix.lower()
|
ext = Path(filename).suffix.lower()
|
||||||
|
|
||||||
if ext == ".pdf":
|
if ext == ".pdf":
|
||||||
sections = parse_pdf(file_bytes)
|
sections = parse_pdf(file_bytes)
|
||||||
|
raw_text = _sections_to_markdown(sections)
|
||||||
elif ext in (".docx", ".doc"):
|
elif ext in (".docx", ".doc"):
|
||||||
sections = parse_docx(file_bytes)
|
sections = parse_docx(file_bytes)
|
||||||
|
raw_text = _sections_to_markdown(sections)
|
||||||
elif ext == ".md":
|
elif ext == ".md":
|
||||||
sections = parse_text(file_bytes, is_markdown=True)
|
raw_text = file_bytes.decode("utf-8", errors="replace")
|
||||||
|
cleaned = clean_markdown_text(raw_text)
|
||||||
|
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True)
|
||||||
elif ext == ".txt":
|
elif ext == ".txt":
|
||||||
sections = parse_text(file_bytes, is_markdown=False)
|
raw_text = file_bytes.decode("utf-8", errors="replace")
|
||||||
|
sections = parse_text(raw_text.encode("utf-8"), is_markdown=False)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported file format: {ext}")
|
raise ValueError(f"Unsupported file format: {ext}")
|
||||||
|
|
||||||
|
# Страховка — чистим секции, даже если в исходнике уже очищали.
|
||||||
|
for s in sections:
|
||||||
|
s.heading = clean_markdown_text(s.heading) if s.heading else ""
|
||||||
|
s.body = clean_markdown_text(s.body)
|
||||||
|
sections = [s for s in sections if s.heading or s.body.strip()]
|
||||||
|
|
||||||
if not sections:
|
if not sections:
|
||||||
logger.warning("No sections found in %s", filename)
|
logger.warning("No sections found in %s", filename)
|
||||||
return document_id, [], []
|
return document_id, raw_text, [], []
|
||||||
|
|
||||||
chunks = chunk_sections(sections)
|
chunks = chunk_sections(sections)
|
||||||
logger.info("Processed '%s': %d sections → %d chunks", filename, len(sections), len(chunks))
|
# Отбрасываем пустые и совсем мелкие хвосты; переиндексируем.
|
||||||
return document_id, sections, chunks
|
chunks = [c for c in chunks if len(c.text.strip()) >= MIN_CHUNK_TEXT_LENGTH]
|
||||||
|
for i, c in enumerate(chunks):
|
||||||
|
c.chunk_index = i
|
||||||
|
logger.info("Processed '%s': %d sections → %d chunks (cleaned)", filename, len(sections), len(chunks))
|
||||||
|
return document_id, raw_text, sections, chunks
|
||||||
|
|
||||||
|
|
||||||
|
def rechunk_raw_text(raw_text: str) -> list[Chunk]:
|
||||||
|
"""Для переиндексации: режем сохранённый текст с актуальными правилами чистки."""
|
||||||
|
cleaned = clean_markdown_text(raw_text)
|
||||||
|
sections = parse_text(cleaned.encode("utf-8"), is_markdown=True)
|
||||||
|
for s in sections:
|
||||||
|
s.heading = clean_markdown_text(s.heading) if s.heading else ""
|
||||||
|
s.body = clean_markdown_text(s.body)
|
||||||
|
sections = [s for s in sections if s.heading or s.body.strip()]
|
||||||
|
chunks = chunk_sections(sections)
|
||||||
|
chunks = [c for c in chunks if len(c.text.strip()) >= MIN_CHUNK_TEXT_LENGTH]
|
||||||
|
for i, c in enumerate(chunks):
|
||||||
|
c.chunk_index = i
|
||||||
|
return chunks
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""SQLite-слой для raw-текстов документов — для переиндексации."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from db.models import Document
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def save_document_raw(
|
||||||
|
session: AsyncSession,
|
||||||
|
document_id: str,
|
||||||
|
name: str,
|
||||||
|
file_type: str,
|
||||||
|
raw_text: str,
|
||||||
|
) -> None:
|
||||||
|
"""Сохранить (или перезаписать) исходный текст документа в SQLite."""
|
||||||
|
existing = await session.get(Document, document_id)
|
||||||
|
if existing:
|
||||||
|
existing.name = name
|
||||||
|
existing.file_type = file_type
|
||||||
|
existing.raw_text = raw_text
|
||||||
|
else:
|
||||||
|
session.add(Document(
|
||||||
|
id=document_id,
|
||||||
|
name=name,
|
||||||
|
file_type=file_type,
|
||||||
|
raw_text=raw_text,
|
||||||
|
))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_document_raw(session: AsyncSession, document_id: str) -> Document | None:
|
||||||
|
return await session.get(Document, document_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_documents_raw(session: AsyncSession) -> list[Document]:
|
||||||
|
stmt = select(Document).order_by(Document.created_at)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_document_raw(session: AsyncSession, document_id: str) -> bool:
|
||||||
|
doc = await session.get(Document, document_id)
|
||||||
|
if doc is None:
|
||||||
|
return False
|
||||||
|
await session.delete(doc)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Чистка wiki-текстов от навигационного и служебного markdown-мусора."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
RETURN_TO_RE = re.compile(r"^\*\*\s*Вернуться на\s*:?\s*\*\*\s*:?$")
|
||||||
|
LINK_ONLY_RE = re.compile(r"^\[[^\]\n]+\]\([^\)\n]+\)$")
|
||||||
|
SOURCE_NOTE_RE = re.compile(r"^_Источник\s*:.*_$")
|
||||||
|
INLINE_LINK_RE = re.compile(r"\[([^\]\n]+)\]\([^\)\n]+\)")
|
||||||
|
MULTI_BLANK_RE = re.compile(r"\n{3,}")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_markdown_text(text: str) -> str:
|
||||||
|
"""Удаляет навигационный мусор и раскрывает инлайн-ссылки.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Строка `**Вернуться на:**` — выбрасывается.
|
||||||
|
- Строка, целиком состоящая из markdown-ссылки `[x](url)` — выбрасывается (это навигация).
|
||||||
|
- Строка `_Источник: .../file.md_` — выбрасывается.
|
||||||
|
- Инлайн-ссылки в теле `[текст](url)` заменяются на `текст`.
|
||||||
|
- 3+ подряд переносов строк сжимаются до 2.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = text.split("\n")
|
||||||
|
cleaned_lines = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if RETURN_TO_RE.match(stripped):
|
||||||
|
continue
|
||||||
|
if LINK_ONLY_RE.match(stripped):
|
||||||
|
continue
|
||||||
|
if SOURCE_NOTE_RE.match(stripped):
|
||||||
|
continue
|
||||||
|
cleaned_lines.append(line)
|
||||||
|
|
||||||
|
text = "\n".join(cleaned_lines)
|
||||||
|
text = INLINE_LINK_RE.sub(r"\1", text)
|
||||||
|
text = MULTI_BLANK_RE.sub("\n\n", text)
|
||||||
|
return text.strip()
|
||||||
Reference in New Issue
Block a user