Spaces:
Runtime error
Runtime error
Commit ·
d4a141c
0
Parent(s):
Deploy GraphRAG FastAPI app to Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +33 -0
- .gitignore +30 -0
- Dockerfile +36 -0
- README.md +35 -0
- app/__init__.py +0 -0
- app/chunking/__init__.py +0 -0
- app/chunking/chunking_service.py +100 -0
- app/core/__init__.py +0 -0
- app/core/config.py +138 -0
- app/deployment/__init__.py +0 -0
- app/deployment/hf_status.py +54 -0
- app/evaluation/__init__.py +0 -0
- app/evaluation/answer_eval_storage.py +92 -0
- app/evaluation/answer_evaluator.py +367 -0
- app/evaluation/retrieval_eval_storage.py +87 -0
- app/evaluation/retrieval_evaluator.py +345 -0
- app/generation/__init__.py +0 -0
- app/generation/answer_quality_checker.py +110 -0
- app/generation/answer_service.py +475 -0
- app/generation/context_cleaner.py +72 -0
- app/generation/evidence_extractor.py +206 -0
- app/generation/llm_service.py +52 -0
- app/generation/prompt_builder.py +37 -0
- app/generation/provider_factory.py +29 -0
- app/generation/providers/__init__.py +0 -0
- app/generation/providers/base_provider.py +27 -0
- app/generation/providers/disabled_provider.py +24 -0
- app/generation/providers/huggingface_provider.py +133 -0
- app/generation/providers/local_provider.py +185 -0
- app/generation/question_classifier.py +69 -0
- app/ingestion/__init__.py +0 -0
- app/ingestion/base_parser.py +15 -0
- app/ingestion/csv_excel_parser.py +107 -0
- app/ingestion/docx_parser.py +120 -0
- app/ingestion/file_detector.py +25 -0
- app/ingestion/html_parser.py +104 -0
- app/ingestion/image_parser.py +69 -0
- app/ingestion/ingestion_service.py +247 -0
- app/ingestion/latex_parser.py +99 -0
- app/ingestion/markdown_parser.py +35 -0
- app/ingestion/parser_registry.py +39 -0
- app/ingestion/pdf_parser.py +45 -0
- app/ingestion/reprocessing_service.py +107 -0
- app/ingestion/txt_parser.py +35 -0
- app/main.py +332 -0
- app/retrieval/__init__.py +0 -0
- app/retrieval/citation_service.py +95 -0
- app/retrieval/embedding_service.py +33 -0
- app/retrieval/hybrid_search_service.py +135 -0
- app/retrieval/indexing_service.py +99 -0
.dockerignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
|
| 4 |
+
venv
|
| 5 |
+
.venv
|
| 6 |
+
env
|
| 7 |
+
|
| 8 |
+
__pycache__
|
| 9 |
+
*.pyc
|
| 10 |
+
*.pyo
|
| 11 |
+
*.pyd
|
| 12 |
+
|
| 13 |
+
.env
|
| 14 |
+
.env.*
|
| 15 |
+
*.log
|
| 16 |
+
|
| 17 |
+
data/uploads
|
| 18 |
+
data/processed
|
| 19 |
+
data/qdrant
|
| 20 |
+
data/evaluation
|
| 21 |
+
|
| 22 |
+
outputs
|
| 23 |
+
reports
|
| 24 |
+
notebooks
|
| 25 |
+
|
| 26 |
+
*.pt
|
| 27 |
+
*.pth
|
| 28 |
+
*.bin
|
| 29 |
+
*.safetensors
|
| 30 |
+
*.onnx
|
| 31 |
+
|
| 32 |
+
.DS_Store
|
| 33 |
+
Thumbs.db
|
.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
.venv/
|
| 3 |
+
env/
|
| 4 |
+
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
| 7 |
+
*.pyo
|
| 8 |
+
*.pyd
|
| 9 |
+
|
| 10 |
+
.env
|
| 11 |
+
.env.*
|
| 12 |
+
*.log
|
| 13 |
+
|
| 14 |
+
data/uploads/
|
| 15 |
+
data/processed/
|
| 16 |
+
data/qdrant/
|
| 17 |
+
data/evaluation/
|
| 18 |
+
|
| 19 |
+
outputs/
|
| 20 |
+
reports/
|
| 21 |
+
notebooks/
|
| 22 |
+
|
| 23 |
+
*.pt
|
| 24 |
+
*.pth
|
| 25 |
+
*.bin
|
| 26 |
+
*.safetensors
|
| 27 |
+
*.onnx
|
| 28 |
+
|
| 29 |
+
.DS_Store
|
| 30 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
RUN useradd -m -u 1000 user
|
| 4 |
+
|
| 5 |
+
ENV HOME=/home/user
|
| 6 |
+
ENV PATH=/home/user/.local/bin:$PATH
|
| 7 |
+
ENV PYTHONUNBUFFERED=1
|
| 8 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 9 |
+
|
| 10 |
+
WORKDIR $HOME/app
|
| 11 |
+
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends build-essential curl git && rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
COPY --chown=user requirements.txt $HOME/app/requirements.txt
|
| 15 |
+
|
| 16 |
+
RUN pip install --no-cache-dir --upgrade pip
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
COPY --chown=user . $HOME/app
|
| 20 |
+
|
| 21 |
+
USER user
|
| 22 |
+
|
| 23 |
+
ENV PORT=7860
|
| 24 |
+
ENV LLM_PROVIDER=huggingface
|
| 25 |
+
ENV ENABLE_LOCAL_LLM=false
|
| 26 |
+
ENV HF_INFERENCE_MODEL=google/flan-t5-base
|
| 27 |
+
ENV HF_TIMEOUT_SECONDS=60
|
| 28 |
+
|
| 29 |
+
ENV UPLOAD_DIR=data/uploads
|
| 30 |
+
ENV PROCESSED_DIR=data/processed
|
| 31 |
+
ENV QDRANT_LOCAL_PATH=data/qdrant
|
| 32 |
+
ENV EVALUATION_DIR=data/evaluation
|
| 33 |
+
|
| 34 |
+
EXPOSE 7860
|
| 35 |
+
|
| 36 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: GraphRAG Research Scientist
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# GraphRAG Research Scientist
|
| 12 |
+
|
| 13 |
+
A FastAPI-based GraphRAG research assistant for document-grounded question answering.
|
| 14 |
+
|
| 15 |
+
## Main endpoints
|
| 16 |
+
|
| 17 |
+
- `/` health check
|
| 18 |
+
- `/demo` simple browser demo
|
| 19 |
+
- `/docs` Swagger API docs
|
| 20 |
+
- `/deployment/health` deployment health
|
| 21 |
+
- `/deployment/config` deployment config
|
| 22 |
+
- `/upload` upload document
|
| 23 |
+
- `/documents/{document_id}/index` index document
|
| 24 |
+
- `/ask` ask question
|
| 25 |
+
|
| 26 |
+
## Hugging Face Variables
|
| 27 |
+
|
| 28 |
+
LLM_PROVIDER=huggingface
|
| 29 |
+
ENABLE_LOCAL_LLM=false
|
| 30 |
+
HF_INFERENCE_MODEL=google/flan-t5-base
|
| 31 |
+
HF_TIMEOUT_SECONDS=60
|
| 32 |
+
|
| 33 |
+
## Hugging Face Secret
|
| 34 |
+
|
| 35 |
+
HF_API_TOKEN should be added in Space Settings as a secret.
|
app/__init__.py
ADDED
|
File without changes
|
app/chunking/__init__.py
ADDED
|
File without changes
|
app/chunking/chunking_service.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 5 |
+
from app.schemas.content_chunk import ContentChunk
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def chunk_blocks(
|
| 9 |
+
blocks: List[RichContentBlock],
|
| 10 |
+
chunk_size: int = settings.DEFAULT_CHUNK_SIZE,
|
| 11 |
+
chunk_overlap: int = settings.DEFAULT_CHUNK_OVERLAP
|
| 12 |
+
) -> List[ContentChunk]:
|
| 13 |
+
|
| 14 |
+
chunks = []
|
| 15 |
+
|
| 16 |
+
for block in blocks:
|
| 17 |
+
if block.content_type == "text":
|
| 18 |
+
chunks.extend(
|
| 19 |
+
chunk_text_block(
|
| 20 |
+
block=block,
|
| 21 |
+
chunk_size=chunk_size,
|
| 22 |
+
chunk_overlap=chunk_overlap
|
| 23 |
+
)
|
| 24 |
+
)
|
| 25 |
+
else:
|
| 26 |
+
chunks.append(create_atomic_chunk(block))
|
| 27 |
+
|
| 28 |
+
return chunks
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def chunk_text_block(
|
| 32 |
+
block: RichContentBlock,
|
| 33 |
+
chunk_size: int,
|
| 34 |
+
chunk_overlap: int
|
| 35 |
+
) -> List[ContentChunk]:
|
| 36 |
+
|
| 37 |
+
text = block.content
|
| 38 |
+
|
| 39 |
+
if not text:
|
| 40 |
+
return []
|
| 41 |
+
|
| 42 |
+
chunks = []
|
| 43 |
+
start = 0
|
| 44 |
+
chunk_index = 0
|
| 45 |
+
text_length = len(text)
|
| 46 |
+
|
| 47 |
+
while start < text_length:
|
| 48 |
+
end = min(start + chunk_size, text_length)
|
| 49 |
+
chunk_text = text[start:end].strip()
|
| 50 |
+
|
| 51 |
+
if chunk_text:
|
| 52 |
+
chunks.append(
|
| 53 |
+
ContentChunk(
|
| 54 |
+
chunk_id=f"{block.block_id}_chunk_{chunk_index + 1}",
|
| 55 |
+
document_id=block.document_id,
|
| 56 |
+
parent_block_id=block.block_id,
|
| 57 |
+
content_type=block.content_type,
|
| 58 |
+
content=chunk_text,
|
| 59 |
+
chunk_index=chunk_index,
|
| 60 |
+
page_number=block.page_number,
|
| 61 |
+
section_title=block.section_title,
|
| 62 |
+
source_file_name=block.source_file_name,
|
| 63 |
+
start_char=start,
|
| 64 |
+
end_char=end,
|
| 65 |
+
metadata={
|
| 66 |
+
"chunking_strategy": "character_overlap",
|
| 67 |
+
"chunk_size": chunk_size,
|
| 68 |
+
"chunk_overlap": chunk_overlap,
|
| 69 |
+
"parent_parser": block.metadata.get("parser")
|
| 70 |
+
}
|
| 71 |
+
)
|
| 72 |
+
)
|
| 73 |
+
chunk_index += 1
|
| 74 |
+
|
| 75 |
+
if end == text_length:
|
| 76 |
+
break
|
| 77 |
+
|
| 78 |
+
start = end - chunk_overlap
|
| 79 |
+
|
| 80 |
+
return chunks
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def create_atomic_chunk(block: RichContentBlock) -> ContentChunk:
|
| 84 |
+
return ContentChunk(
|
| 85 |
+
chunk_id=f"{block.block_id}_chunk_1",
|
| 86 |
+
document_id=block.document_id,
|
| 87 |
+
parent_block_id=block.block_id,
|
| 88 |
+
content_type=block.content_type,
|
| 89 |
+
content=block.content,
|
| 90 |
+
chunk_index=0,
|
| 91 |
+
page_number=block.page_number,
|
| 92 |
+
section_title=block.section_title,
|
| 93 |
+
source_file_name=block.source_file_name,
|
| 94 |
+
start_char=0,
|
| 95 |
+
end_char=len(block.content),
|
| 96 |
+
metadata={
|
| 97 |
+
"chunking_strategy": "atomic_rich_content_block",
|
| 98 |
+
"reason": "non-text content should not be split"
|
| 99 |
+
}
|
| 100 |
+
)
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/config.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def get_int_env(variable_name: str, default_value: int) -> int:
|
| 7 |
+
value = os.getenv(variable_name)
|
| 8 |
+
|
| 9 |
+
if value is None:
|
| 10 |
+
return default_value
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
return int(value)
|
| 14 |
+
except ValueError:
|
| 15 |
+
return default_value
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_float_env(variable_name: str, default_value: float) -> float:
|
| 19 |
+
value = os.getenv(variable_name)
|
| 20 |
+
|
| 21 |
+
if value is None:
|
| 22 |
+
return default_value
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
return float(value)
|
| 26 |
+
except ValueError:
|
| 27 |
+
return default_value
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_bool_env(variable_name: str, default_value: bool) -> bool:
|
| 31 |
+
value = os.getenv(variable_name)
|
| 32 |
+
|
| 33 |
+
if value is None:
|
| 34 |
+
return default_value
|
| 35 |
+
|
| 36 |
+
value = value.lower().strip()
|
| 37 |
+
|
| 38 |
+
if value in ["true", "1", "yes", "y"]:
|
| 39 |
+
return True
|
| 40 |
+
|
| 41 |
+
if value in ["false", "0", "no", "n"]:
|
| 42 |
+
return False
|
| 43 |
+
|
| 44 |
+
return default_value
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass(frozen=True)
|
| 48 |
+
class Settings:
|
| 49 |
+
APP_NAME: str = "GraphRAG Research Scientist"
|
| 50 |
+
APP_VERSION: str = "10.0.0"
|
| 51 |
+
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "local")
|
| 52 |
+
|
| 53 |
+
UPLOAD_DIR: Path = Path(os.getenv("UPLOAD_DIR", "data/uploads"))
|
| 54 |
+
PROCESSED_DIR: Path = Path(os.getenv("PROCESSED_DIR", "data/processed"))
|
| 55 |
+
QDRANT_LOCAL_PATH: Path = Path(os.getenv("QDRANT_LOCAL_PATH", "data/qdrant"))
|
| 56 |
+
EVALUATION_DIR: Path = Path(os.getenv("EVALUATION_DIR", "data/evaluation"))
|
| 57 |
+
|
| 58 |
+
DEFAULT_CHUNK_SIZE: int = get_int_env("DEFAULT_CHUNK_SIZE", 1000)
|
| 59 |
+
DEFAULT_CHUNK_OVERLAP: int = get_int_env("DEFAULT_CHUNK_OVERLAP", 150)
|
| 60 |
+
MAX_ROWS_PER_TABLE_BLOCK: int = get_int_env("MAX_ROWS_PER_TABLE_BLOCK", 50)
|
| 61 |
+
MAX_UPLOAD_SIZE_MB: int = get_int_env("MAX_UPLOAD_SIZE_MB", 100)
|
| 62 |
+
|
| 63 |
+
EMBEDDING_MODEL_NAME: str = os.getenv(
|
| 64 |
+
"EMBEDDING_MODEL_NAME",
|
| 65 |
+
"sentence-transformers/all-MiniLM-L6-v2"
|
| 66 |
+
)
|
| 67 |
+
EMBEDDING_DIMENSION: int = get_int_env("EMBEDDING_DIMENSION", 384)
|
| 68 |
+
|
| 69 |
+
QDRANT_COLLECTION_NAME: str = os.getenv(
|
| 70 |
+
"QDRANT_COLLECTION_NAME",
|
| 71 |
+
"research_chunks"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
DEFAULT_TOP_K: int = get_int_env("DEFAULT_TOP_K", 5)
|
| 75 |
+
|
| 76 |
+
HYBRID_VECTOR_WEIGHT: float = get_float_env("HYBRID_VECTOR_WEIGHT", 0.65)
|
| 77 |
+
HYBRID_KEYWORD_WEIGHT: float = get_float_env("HYBRID_KEYWORD_WEIGHT", 0.35)
|
| 78 |
+
|
| 79 |
+
ENABLE_RERANKER: bool = get_bool_env("ENABLE_RERANKER", True)
|
| 80 |
+
RERANKER_MODEL_NAME: str = os.getenv(
|
| 81 |
+
"RERANKER_MODEL_NAME",
|
| 82 |
+
"cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 83 |
+
)
|
| 84 |
+
RERANKER_CANDIDATE_MULTIPLIER: int = get_int_env(
|
| 85 |
+
"RERANKER_CANDIDATE_MULTIPLIER",
|
| 86 |
+
4
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# =====================================================
|
| 90 |
+
# LLM provider settings
|
| 91 |
+
# =====================================================
|
| 92 |
+
ENABLE_LOCAL_LLM: bool = get_bool_env("ENABLE_LOCAL_LLM", True)
|
| 93 |
+
|
| 94 |
+
# Supported now:
|
| 95 |
+
# local
|
| 96 |
+
# huggingface
|
| 97 |
+
# disabled
|
| 98 |
+
#
|
| 99 |
+
# Future:
|
| 100 |
+
# aws_bedrock
|
| 101 |
+
# openai
|
| 102 |
+
LLM_PROVIDER: str = os.getenv("LLM_PROVIDER", "local")
|
| 103 |
+
|
| 104 |
+
LOCAL_LLM_MODEL_NAME: str = os.getenv(
|
| 105 |
+
"LOCAL_LLM_MODEL_NAME",
|
| 106 |
+
"google/flan-t5-base"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
LOCAL_LLM_DEVICE: str = os.getenv("LOCAL_LLM_DEVICE", "cpu")
|
| 110 |
+
|
| 111 |
+
HF_API_TOKEN: str = os.getenv("HF_API_TOKEN", "")
|
| 112 |
+
HF_INFERENCE_MODEL: str = os.getenv(
|
| 113 |
+
"HF_INFERENCE_MODEL",
|
| 114 |
+
"google/flan-t5-base"
|
| 115 |
+
)
|
| 116 |
+
HF_INFERENCE_URL: str = os.getenv(
|
| 117 |
+
"HF_INFERENCE_URL",
|
| 118 |
+
""
|
| 119 |
+
)
|
| 120 |
+
HF_TIMEOUT_SECONDS: int = get_int_env("HF_TIMEOUT_SECONDS", 60)
|
| 121 |
+
|
| 122 |
+
MAX_GENERATION_TOKENS: int = get_int_env("MAX_GENERATION_TOKENS", 220)
|
| 123 |
+
LOCAL_LLM_MAX_INPUT_TOKENS: int = get_int_env("LOCAL_LLM_MAX_INPUT_TOKENS", 1024)
|
| 124 |
+
|
| 125 |
+
MIN_LLM_ANSWER_WORDS: int = get_int_env("MIN_LLM_ANSWER_WORDS", 20)
|
| 126 |
+
MAX_CONTEXT_CHARS: int = get_int_env("MAX_CONTEXT_CHARS", 5000)
|
| 127 |
+
|
| 128 |
+
ENABLE_STATIC_ASSETS: bool = get_bool_env("ENABLE_STATIC_ASSETS", True)
|
| 129 |
+
|
| 130 |
+
def ensure_directories(self) -> None:
|
| 131 |
+
self.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 132 |
+
self.PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
|
| 133 |
+
self.QDRANT_LOCAL_PATH.mkdir(parents=True, exist_ok=True)
|
| 134 |
+
self.EVALUATION_DIR.mkdir(parents=True, exist_ok=True)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
settings = Settings()
|
| 138 |
+
settings.ensure_directories()
|
app/deployment/__init__.py
ADDED
|
File without changes
|
app/deployment/hf_status.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def get_deployment_health() -> Dict[str, Any]:
|
| 8 |
+
return {
|
| 9 |
+
"status": "healthy",
|
| 10 |
+
"service": settings.APP_NAME,
|
| 11 |
+
"version": settings.APP_VERSION,
|
| 12 |
+
"environment": settings.ENVIRONMENT,
|
| 13 |
+
"deployment_target": "hugging_face_spaces",
|
| 14 |
+
"port": os.getenv("PORT", "7860"),
|
| 15 |
+
"message": "FastAPI app is running and ready for Hugging Face Spaces."
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_deployment_config() -> Dict[str, Any]:
|
| 20 |
+
return {
|
| 21 |
+
"deployment_target": "hugging_face_spaces",
|
| 22 |
+
"llm_provider": settings.LLM_PROVIDER,
|
| 23 |
+
"local_llm_enabled": settings.ENABLE_LOCAL_LLM,
|
| 24 |
+
"hf_model": settings.HF_INFERENCE_MODEL,
|
| 25 |
+
"hf_token_present": bool(settings.HF_API_TOKEN),
|
| 26 |
+
"upload_dir": str(settings.UPLOAD_DIR),
|
| 27 |
+
"processed_dir": str(settings.PROCESSED_DIR),
|
| 28 |
+
"qdrant_path": str(settings.QDRANT_LOCAL_PATH),
|
| 29 |
+
"evaluation_dir": str(settings.EVALUATION_DIR),
|
| 30 |
+
"reranker_enabled": settings.ENABLE_RERANKER,
|
| 31 |
+
"storage_warning": "Local Space storage can reset after restart unless persistent storage is attached."
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_demo_html() -> str:
|
| 36 |
+
return """
|
| 37 |
+
<!DOCTYPE html>
|
| 38 |
+
<html>
|
| 39 |
+
<head>
|
| 40 |
+
<title>GraphRAG Research Scientist</title>
|
| 41 |
+
</head>
|
| 42 |
+
<body style="font-family: Arial; max-width: 900px; margin: 40px auto; line-height: 1.6;">
|
| 43 |
+
<h1>🧠 GraphRAG Research Scientist</h1>
|
| 44 |
+
<p>FastAPI backend is running.</p>
|
| 45 |
+
<h2>Useful links</h2>
|
| 46 |
+
<ul>
|
| 47 |
+
<li><a href="/docs">Swagger API Docs</a></li>
|
| 48 |
+
<li><a href="/deployment/health">Deployment Health</a></li>
|
| 49 |
+
<li><a href="/deployment/config">Deployment Config</a></li>
|
| 50 |
+
<li><a href="/llm/status">LLM Status</a></li>
|
| 51 |
+
</ul>
|
| 52 |
+
</body>
|
| 53 |
+
</html>
|
| 54 |
+
"""
|
app/evaluation/__init__.py
ADDED
|
File without changes
|
app/evaluation/answer_eval_storage.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from app.core.config import settings
|
| 6 |
+
from app.schemas.evaluation_schema import (
|
| 7 |
+
AnswerTestCase,
|
| 8 |
+
AnswerTestCaseCreate
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ANSWER_TEST_CASES_PATH = settings.EVALUATION_DIR / "answer_test_cases.json"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_answer_test_cases() -> List[AnswerTestCase]:
|
| 16 |
+
settings.EVALUATION_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
if not ANSWER_TEST_CASES_PATH.exists():
|
| 19 |
+
return []
|
| 20 |
+
|
| 21 |
+
with open(ANSWER_TEST_CASES_PATH, "r", encoding="utf-8") as f:
|
| 22 |
+
data = json.load(f)
|
| 23 |
+
|
| 24 |
+
return [AnswerTestCase(**item) for item in data]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def save_answer_test_cases(test_cases: List[AnswerTestCase]) -> None:
|
| 28 |
+
settings.EVALUATION_DIR.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
with open(ANSWER_TEST_CASES_PATH, "w", encoding="utf-8") as f:
|
| 31 |
+
json.dump(
|
| 32 |
+
[test_case.model_dump() for test_case in test_cases],
|
| 33 |
+
f,
|
| 34 |
+
indent=2,
|
| 35 |
+
ensure_ascii=False
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def add_answer_test_case(
|
| 40 |
+
test_case_create: AnswerTestCaseCreate
|
| 41 |
+
) -> AnswerTestCase:
|
| 42 |
+
|
| 43 |
+
test_cases = load_answer_test_cases()
|
| 44 |
+
|
| 45 |
+
new_test_case = AnswerTestCase(
|
| 46 |
+
test_case_id=str(uuid.uuid4()),
|
| 47 |
+
question=test_case_create.question,
|
| 48 |
+
document_id=test_case_create.document_id,
|
| 49 |
+
top_k=test_case_create.top_k,
|
| 50 |
+
retrieval_mode=test_case_create.retrieval_mode,
|
| 51 |
+
use_reranker=test_case_create.use_reranker,
|
| 52 |
+
use_llm=test_case_create.use_llm,
|
| 53 |
+
expected_answer_keywords=test_case_create.expected_answer_keywords,
|
| 54 |
+
forbidden_answer_keywords=test_case_create.forbidden_answer_keywords,
|
| 55 |
+
require_citations=test_case_create.require_citations,
|
| 56 |
+
require_sources=test_case_create.require_sources,
|
| 57 |
+
minimum_answer_words=test_case_create.minimum_answer_words,
|
| 58 |
+
minimum_keyword_match_ratio=test_case_create.minimum_keyword_match_ratio,
|
| 59 |
+
minimum_groundedness_score=test_case_create.minimum_groundedness_score,
|
| 60 |
+
notes=test_case_create.notes,
|
| 61 |
+
tags=test_case_create.tags
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
test_cases.append(new_test_case)
|
| 65 |
+
save_answer_test_cases(test_cases)
|
| 66 |
+
|
| 67 |
+
return new_test_case
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def delete_answer_test_case(test_case_id: str) -> bool:
|
| 71 |
+
test_cases = load_answer_test_cases()
|
| 72 |
+
|
| 73 |
+
remaining_cases = [
|
| 74 |
+
test_case for test_case in test_cases
|
| 75 |
+
if test_case.test_case_id != test_case_id
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
if len(remaining_cases) == len(test_cases):
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
save_answer_test_cases(remaining_cases)
|
| 82 |
+
return True
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def get_answer_test_case(test_case_id: str) -> Optional[AnswerTestCase]:
|
| 86 |
+
test_cases = load_answer_test_cases()
|
| 87 |
+
|
| 88 |
+
for test_case in test_cases:
|
| 89 |
+
if test_case.test_case_id == test_case_id:
|
| 90 |
+
return test_case
|
| 91 |
+
|
| 92 |
+
return None
|
app/evaluation/answer_evaluator.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import List, Optional, Dict, Any
|
| 3 |
+
|
| 4 |
+
from app.schemas.evaluation_schema import (
|
| 5 |
+
AnswerTestCase,
|
| 6 |
+
AnswerEvaluationRunRequest,
|
| 7 |
+
AnswerSingleResult,
|
| 8 |
+
AnswerEvaluationSummary,
|
| 9 |
+
AnswerEvaluationReport
|
| 10 |
+
)
|
| 11 |
+
from app.evaluation.answer_eval_storage import load_answer_test_cases
|
| 12 |
+
from app.generation.answer_service import answer_question
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
STOPWORDS = {
|
| 16 |
+
"the", "a", "an", "and", "or", "of", "to", "in", "on", "by", "for",
|
| 17 |
+
"with", "from", "is", "are", "was", "were", "be", "been", "it",
|
| 18 |
+
"this", "that", "as", "at", "which", "what", "how", "why"
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def run_answer_evaluation(
|
| 23 |
+
request: AnswerEvaluationRunRequest
|
| 24 |
+
) -> AnswerEvaluationReport:
|
| 25 |
+
|
| 26 |
+
all_test_cases = load_answer_test_cases()
|
| 27 |
+
|
| 28 |
+
if request.test_case_ids:
|
| 29 |
+
selected_ids = set(request.test_case_ids)
|
| 30 |
+
test_cases = [
|
| 31 |
+
test_case for test_case in all_test_cases
|
| 32 |
+
if test_case.test_case_id in selected_ids
|
| 33 |
+
]
|
| 34 |
+
else:
|
| 35 |
+
test_cases = all_test_cases
|
| 36 |
+
|
| 37 |
+
results = []
|
| 38 |
+
|
| 39 |
+
for test_case in test_cases:
|
| 40 |
+
result = evaluate_single_answer_test_case(
|
| 41 |
+
test_case=test_case,
|
| 42 |
+
use_llm_override=request.use_llm_override,
|
| 43 |
+
retrieval_mode_override=request.retrieval_mode_override
|
| 44 |
+
)
|
| 45 |
+
results.append(result)
|
| 46 |
+
|
| 47 |
+
summary = build_answer_evaluation_summary(results)
|
| 48 |
+
|
| 49 |
+
return AnswerEvaluationReport(
|
| 50 |
+
summary=summary,
|
| 51 |
+
results=results
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def evaluate_single_answer_test_case(
|
| 56 |
+
test_case: AnswerTestCase,
|
| 57 |
+
use_llm_override: Optional[bool] = None,
|
| 58 |
+
retrieval_mode_override: Optional[str] = None
|
| 59 |
+
) -> AnswerSingleResult:
|
| 60 |
+
|
| 61 |
+
use_llm = (
|
| 62 |
+
use_llm_override
|
| 63 |
+
if use_llm_override is not None
|
| 64 |
+
else test_case.use_llm
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
retrieval_mode = retrieval_mode_override or test_case.retrieval_mode
|
| 68 |
+
|
| 69 |
+
answer_output = answer_question(
|
| 70 |
+
query=test_case.question,
|
| 71 |
+
document_id=test_case.document_id,
|
| 72 |
+
top_k=test_case.top_k,
|
| 73 |
+
retrieval_mode=retrieval_mode,
|
| 74 |
+
use_reranker=test_case.use_reranker,
|
| 75 |
+
use_llm=use_llm
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
answer = answer_output.get("answer", "")
|
| 79 |
+
citations = answer_output.get("citations", [])
|
| 80 |
+
sources = answer_output.get("sources", [])
|
| 81 |
+
|
| 82 |
+
answer_word_count = count_words(answer)
|
| 83 |
+
citation_present = has_citation(answer)
|
| 84 |
+
source_count = len(sources)
|
| 85 |
+
|
| 86 |
+
matched_keywords, missing_keywords, keyword_match_ratio = evaluate_keywords(
|
| 87 |
+
answer=answer,
|
| 88 |
+
expected_keywords=test_case.expected_answer_keywords
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
forbidden_keywords_found = find_forbidden_keywords(
|
| 92 |
+
answer=answer,
|
| 93 |
+
forbidden_keywords=test_case.forbidden_answer_keywords
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
groundedness_score = compute_groundedness_score(
|
| 97 |
+
answer=answer,
|
| 98 |
+
sources=sources
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
groundedness_passed = (
|
| 102 |
+
groundedness_score >= test_case.minimum_groundedness_score
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
failure_reasons = []
|
| 106 |
+
|
| 107 |
+
if answer_word_count < test_case.minimum_answer_words:
|
| 108 |
+
failure_reasons.append(
|
| 109 |
+
f"Answer is too short. Expected at least {test_case.minimum_answer_words} words."
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if test_case.require_citations and not citation_present:
|
| 113 |
+
failure_reasons.append("Answer does not contain required citations.")
|
| 114 |
+
|
| 115 |
+
if test_case.require_sources and source_count == 0:
|
| 116 |
+
failure_reasons.append("Answer does not include any retrieved sources.")
|
| 117 |
+
|
| 118 |
+
if test_case.expected_answer_keywords:
|
| 119 |
+
if keyword_match_ratio < test_case.minimum_keyword_match_ratio:
|
| 120 |
+
failure_reasons.append(
|
| 121 |
+
"Answer did not match enough expected keywords."
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if forbidden_keywords_found:
|
| 125 |
+
failure_reasons.append(
|
| 126 |
+
"Answer contains forbidden keywords."
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
if not groundedness_passed:
|
| 130 |
+
failure_reasons.append(
|
| 131 |
+
"Answer does not appear grounded enough in retrieved sources."
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
passed = len(failure_reasons) == 0
|
| 135 |
+
|
| 136 |
+
return AnswerSingleResult(
|
| 137 |
+
test_case_id=test_case.test_case_id,
|
| 138 |
+
question=test_case.question,
|
| 139 |
+
passed=passed,
|
| 140 |
+
failure_reasons=failure_reasons,
|
| 141 |
+
answer=answer,
|
| 142 |
+
answer_strategy=answer_output.get("answer_strategy"),
|
| 143 |
+
used_llm=answer_output.get("used_llm", False),
|
| 144 |
+
used_reranker=answer_output.get("used_reranker", False),
|
| 145 |
+
retrieval_mode=answer_output.get("retrieval_mode", retrieval_mode),
|
| 146 |
+
answer_word_count=answer_word_count,
|
| 147 |
+
citation_present=citation_present,
|
| 148 |
+
source_count=source_count,
|
| 149 |
+
keyword_match_ratio=keyword_match_ratio,
|
| 150 |
+
matched_keywords=matched_keywords,
|
| 151 |
+
missing_keywords=missing_keywords,
|
| 152 |
+
forbidden_keywords_found=forbidden_keywords_found,
|
| 153 |
+
groundedness_score=groundedness_score,
|
| 154 |
+
groundedness_passed=groundedness_passed,
|
| 155 |
+
citations_preview=simplify_citations(citations),
|
| 156 |
+
sources_preview=simplify_sources(sources)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def count_words(text: str) -> int:
|
| 161 |
+
return len(re.findall(r"[a-zA-Z0-9_]+", text or ""))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def has_citation(text: str) -> bool:
|
| 165 |
+
if not text:
|
| 166 |
+
return False
|
| 167 |
+
|
| 168 |
+
return bool(re.search(r"\[S\d+\]", text))
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def evaluate_keywords(
|
| 172 |
+
answer: str,
|
| 173 |
+
expected_keywords: List[str]
|
| 174 |
+
):
|
| 175 |
+
if not expected_keywords:
|
| 176 |
+
return [], [], None
|
| 177 |
+
|
| 178 |
+
answer_lower = answer.lower()
|
| 179 |
+
|
| 180 |
+
matched_keywords = []
|
| 181 |
+
missing_keywords = []
|
| 182 |
+
|
| 183 |
+
for keyword in expected_keywords:
|
| 184 |
+
keyword_lower = keyword.lower().strip()
|
| 185 |
+
|
| 186 |
+
if keyword_lower in answer_lower:
|
| 187 |
+
matched_keywords.append(keyword)
|
| 188 |
+
else:
|
| 189 |
+
missing_keywords.append(keyword)
|
| 190 |
+
|
| 191 |
+
keyword_match_ratio = round(
|
| 192 |
+
len(matched_keywords) / len(expected_keywords),
|
| 193 |
+
4
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
return matched_keywords, missing_keywords, keyword_match_ratio
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def find_forbidden_keywords(
|
| 200 |
+
answer: str,
|
| 201 |
+
forbidden_keywords: List[str]
|
| 202 |
+
) -> List[str]:
|
| 203 |
+
|
| 204 |
+
if not forbidden_keywords:
|
| 205 |
+
return []
|
| 206 |
+
|
| 207 |
+
answer_lower = answer.lower()
|
| 208 |
+
found = []
|
| 209 |
+
|
| 210 |
+
for keyword in forbidden_keywords:
|
| 211 |
+
keyword_lower = keyword.lower().strip()
|
| 212 |
+
|
| 213 |
+
if keyword_lower in answer_lower:
|
| 214 |
+
found.append(keyword)
|
| 215 |
+
|
| 216 |
+
return found
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def tokenize_for_groundedness(text: str) -> set:
|
| 220 |
+
words = re.findall(r"[a-zA-Z0-9_]+", (text or "").lower())
|
| 221 |
+
|
| 222 |
+
tokens = {
|
| 223 |
+
word for word in words
|
| 224 |
+
if word not in STOPWORDS and len(word) > 2
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
return tokens
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def compute_groundedness_score(
|
| 231 |
+
answer: str,
|
| 232 |
+
sources: List[Dict[str, Any]]
|
| 233 |
+
) -> float:
|
| 234 |
+
|
| 235 |
+
answer_tokens = tokenize_for_groundedness(answer)
|
| 236 |
+
|
| 237 |
+
if not answer_tokens:
|
| 238 |
+
return 0.0
|
| 239 |
+
|
| 240 |
+
source_text = " ".join(
|
| 241 |
+
source.get("content", "")
|
| 242 |
+
for source in sources
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
source_tokens = tokenize_for_groundedness(source_text)
|
| 246 |
+
|
| 247 |
+
if not source_tokens:
|
| 248 |
+
return 0.0
|
| 249 |
+
|
| 250 |
+
overlap = answer_tokens.intersection(source_tokens)
|
| 251 |
+
|
| 252 |
+
score = len(overlap) / len(answer_tokens)
|
| 253 |
+
|
| 254 |
+
return round(score, 4)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def simplify_citations(citations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 258 |
+
simplified = []
|
| 259 |
+
|
| 260 |
+
for citation in citations[:5]:
|
| 261 |
+
simplified.append(
|
| 262 |
+
{
|
| 263 |
+
"source_id": citation.get("source_id"),
|
| 264 |
+
"source_file_name": citation.get("source_file_name"),
|
| 265 |
+
"page_number": citation.get("page_number"),
|
| 266 |
+
"citation_text": citation.get("citation_text")
|
| 267 |
+
}
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
return simplified
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def simplify_sources(sources: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 274 |
+
simplified = []
|
| 275 |
+
|
| 276 |
+
for source in sources[:5]:
|
| 277 |
+
content = source.get("content", "")
|
| 278 |
+
|
| 279 |
+
simplified.append(
|
| 280 |
+
{
|
| 281 |
+
"source_id": source.get("source_id"),
|
| 282 |
+
"score": source.get("score"),
|
| 283 |
+
"chunk_id": source.get("chunk_id"),
|
| 284 |
+
"source_file_name": source.get("source_file_name"),
|
| 285 |
+
"page_number": source.get("page_number"),
|
| 286 |
+
"content_preview": content[:250]
|
| 287 |
+
}
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
return simplified
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def build_answer_evaluation_summary(
|
| 294 |
+
results: List[AnswerSingleResult]
|
| 295 |
+
) -> AnswerEvaluationSummary:
|
| 296 |
+
|
| 297 |
+
total_cases = len(results)
|
| 298 |
+
|
| 299 |
+
if total_cases == 0:
|
| 300 |
+
return AnswerEvaluationSummary(
|
| 301 |
+
total_cases=0,
|
| 302 |
+
passed_cases=0,
|
| 303 |
+
failed_cases=0,
|
| 304 |
+
pass_rate=0.0,
|
| 305 |
+
average_groundedness_score=0.0,
|
| 306 |
+
average_answer_word_count=0.0
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
passed_cases = sum(1 for result in results if result.passed)
|
| 310 |
+
failed_cases = total_cases - passed_cases
|
| 311 |
+
|
| 312 |
+
pass_rate = round(passed_cases / total_cases, 4)
|
| 313 |
+
|
| 314 |
+
citation_pass_rate = round(
|
| 315 |
+
sum(1 for result in results if result.citation_present) / total_cases,
|
| 316 |
+
4
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
source_presence_rate = round(
|
| 320 |
+
sum(1 for result in results if result.source_count > 0) / total_cases,
|
| 321 |
+
4
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
keyword_results = [
|
| 325 |
+
result for result in results
|
| 326 |
+
if result.keyword_match_ratio is not None
|
| 327 |
+
]
|
| 328 |
+
|
| 329 |
+
keyword_pass_rate = None
|
| 330 |
+
|
| 331 |
+
if keyword_results:
|
| 332 |
+
keyword_pass_rate = round(
|
| 333 |
+
sum(
|
| 334 |
+
1 for result in keyword_results
|
| 335 |
+
if result.keyword_match_ratio is not None
|
| 336 |
+
and result.keyword_match_ratio >= 0.5
|
| 337 |
+
) / len(keyword_results),
|
| 338 |
+
4
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
groundedness_pass_rate = round(
|
| 342 |
+
sum(1 for result in results if result.groundedness_passed) / total_cases,
|
| 343 |
+
4
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
average_groundedness_score = round(
|
| 347 |
+
sum(result.groundedness_score for result in results) / total_cases,
|
| 348 |
+
4
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
average_answer_word_count = round(
|
| 352 |
+
sum(result.answer_word_count for result in results) / total_cases,
|
| 353 |
+
2
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
return AnswerEvaluationSummary(
|
| 357 |
+
total_cases=total_cases,
|
| 358 |
+
passed_cases=passed_cases,
|
| 359 |
+
failed_cases=failed_cases,
|
| 360 |
+
pass_rate=pass_rate,
|
| 361 |
+
citation_pass_rate=citation_pass_rate,
|
| 362 |
+
source_presence_rate=source_presence_rate,
|
| 363 |
+
keyword_pass_rate=keyword_pass_rate,
|
| 364 |
+
groundedness_pass_rate=groundedness_pass_rate,
|
| 365 |
+
average_groundedness_score=average_groundedness_score,
|
| 366 |
+
average_answer_word_count=average_answer_word_count
|
| 367 |
+
)
|
app/evaluation/retrieval_eval_storage.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
|
| 5 |
+
from app.core.config import settings
|
| 6 |
+
from app.schemas.evaluation_schema import (
|
| 7 |
+
RetrievalTestCase,
|
| 8 |
+
RetrievalTestCaseCreate
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
TEST_CASES_PATH = settings.EVALUATION_DIR / "retrieval_test_cases.json"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_retrieval_test_cases() -> List[RetrievalTestCase]:
|
| 16 |
+
settings.EVALUATION_DIR.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
|
| 18 |
+
if not TEST_CASES_PATH.exists():
|
| 19 |
+
return []
|
| 20 |
+
|
| 21 |
+
with open(TEST_CASES_PATH, "r", encoding="utf-8") as f:
|
| 22 |
+
data = json.load(f)
|
| 23 |
+
|
| 24 |
+
return [RetrievalTestCase(**item) for item in data]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def save_retrieval_test_cases(test_cases: List[RetrievalTestCase]) -> None:
|
| 28 |
+
settings.EVALUATION_DIR.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
with open(TEST_CASES_PATH, "w", encoding="utf-8") as f:
|
| 31 |
+
json.dump(
|
| 32 |
+
[test_case.model_dump() for test_case in test_cases],
|
| 33 |
+
f,
|
| 34 |
+
indent=2,
|
| 35 |
+
ensure_ascii=False
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def add_retrieval_test_case(
|
| 40 |
+
test_case_create: RetrievalTestCaseCreate
|
| 41 |
+
) -> RetrievalTestCase:
|
| 42 |
+
|
| 43 |
+
test_cases = load_retrieval_test_cases()
|
| 44 |
+
|
| 45 |
+
new_test_case = RetrievalTestCase(
|
| 46 |
+
test_case_id=str(uuid.uuid4()),
|
| 47 |
+
question=test_case_create.question,
|
| 48 |
+
expected_document_id=test_case_create.expected_document_id,
|
| 49 |
+
expected_source_file_name=test_case_create.expected_source_file_name,
|
| 50 |
+
expected_page_numbers=test_case_create.expected_page_numbers,
|
| 51 |
+
expected_chunk_ids=test_case_create.expected_chunk_ids,
|
| 52 |
+
search_document_id=test_case_create.search_document_id,
|
| 53 |
+
top_k=test_case_create.top_k,
|
| 54 |
+
retrieval_mode=test_case_create.retrieval_mode,
|
| 55 |
+
notes=test_case_create.notes,
|
| 56 |
+
tags=test_case_create.tags
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
test_cases.append(new_test_case)
|
| 60 |
+
save_retrieval_test_cases(test_cases)
|
| 61 |
+
|
| 62 |
+
return new_test_case
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def delete_retrieval_test_case(test_case_id: str) -> bool:
|
| 66 |
+
test_cases = load_retrieval_test_cases()
|
| 67 |
+
|
| 68 |
+
remaining_cases = [
|
| 69 |
+
test_case for test_case in test_cases
|
| 70 |
+
if test_case.test_case_id != test_case_id
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
if len(remaining_cases) == len(test_cases):
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
save_retrieval_test_cases(remaining_cases)
|
| 77 |
+
return True
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_retrieval_test_case(test_case_id: str) -> Optional[RetrievalTestCase]:
|
| 81 |
+
test_cases = load_retrieval_test_cases()
|
| 82 |
+
|
| 83 |
+
for test_case in test_cases:
|
| 84 |
+
if test_case.test_case_id == test_case_id:
|
| 85 |
+
return test_case
|
| 86 |
+
|
| 87 |
+
return None
|
app/evaluation/retrieval_evaluator.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Dict, Any
|
| 2 |
+
|
| 3 |
+
from app.schemas.evaluation_schema import (
|
| 4 |
+
RetrievalTestCase,
|
| 5 |
+
RetrievalEvaluationRunRequest,
|
| 6 |
+
RetrievalSingleResult,
|
| 7 |
+
RetrievalEvaluationSummary,
|
| 8 |
+
RetrievalEvaluationReport
|
| 9 |
+
)
|
| 10 |
+
from app.evaluation.retrieval_eval_storage import load_retrieval_test_cases
|
| 11 |
+
from app.retrieval.hybrid_search_service import retrieve_chunks
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def run_retrieval_evaluation(
|
| 15 |
+
request: RetrievalEvaluationRunRequest
|
| 16 |
+
) -> RetrievalEvaluationReport:
|
| 17 |
+
|
| 18 |
+
all_test_cases = load_retrieval_test_cases()
|
| 19 |
+
|
| 20 |
+
if request.test_case_ids:
|
| 21 |
+
selected_ids = set(request.test_case_ids)
|
| 22 |
+
test_cases = [
|
| 23 |
+
test_case for test_case in all_test_cases
|
| 24 |
+
if test_case.test_case_id in selected_ids
|
| 25 |
+
]
|
| 26 |
+
else:
|
| 27 |
+
test_cases = all_test_cases
|
| 28 |
+
|
| 29 |
+
results = []
|
| 30 |
+
|
| 31 |
+
for test_case in test_cases:
|
| 32 |
+
result = evaluate_single_test_case(
|
| 33 |
+
test_case=test_case,
|
| 34 |
+
top_k_override=request.top_k_override,
|
| 35 |
+
retrieval_mode_override=request.retrieval_mode_override
|
| 36 |
+
)
|
| 37 |
+
results.append(result)
|
| 38 |
+
|
| 39 |
+
summary = build_evaluation_summary(results)
|
| 40 |
+
|
| 41 |
+
return RetrievalEvaluationReport(
|
| 42 |
+
summary=summary,
|
| 43 |
+
results=results
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def evaluate_single_test_case(
|
| 48 |
+
test_case: RetrievalTestCase,
|
| 49 |
+
top_k_override: Optional[int] = None,
|
| 50 |
+
retrieval_mode_override: Optional[str] = None
|
| 51 |
+
) -> RetrievalSingleResult:
|
| 52 |
+
|
| 53 |
+
top_k = top_k_override or test_case.top_k
|
| 54 |
+
retrieval_mode = retrieval_mode_override or test_case.retrieval_mode
|
| 55 |
+
|
| 56 |
+
retrieval_output = retrieve_chunks(
|
| 57 |
+
query=test_case.question,
|
| 58 |
+
document_id=test_case.search_document_id,
|
| 59 |
+
top_k=top_k,
|
| 60 |
+
retrieval_mode=retrieval_mode
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
retrieved_results = retrieval_output.get("results", [])
|
| 64 |
+
|
| 65 |
+
expected_document_hit = evaluate_expected_document_hit(
|
| 66 |
+
retrieved_results,
|
| 67 |
+
test_case.expected_document_id
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
expected_source_file_hit = evaluate_expected_source_file_hit(
|
| 71 |
+
retrieved_results,
|
| 72 |
+
test_case.expected_source_file_name
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
expected_page_hit = evaluate_expected_page_hit(
|
| 76 |
+
retrieved_results,
|
| 77 |
+
test_case.expected_page_numbers
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
expected_chunk_hit = evaluate_expected_chunk_hit(
|
| 81 |
+
retrieved_results,
|
| 82 |
+
test_case.expected_chunk_ids
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
best_match_rank = find_best_match_rank(
|
| 86 |
+
retrieved_results=retrieved_results,
|
| 87 |
+
test_case=test_case
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
reciprocal_rank = 0.0
|
| 91 |
+
|
| 92 |
+
if best_match_rank is not None and best_match_rank > 0:
|
| 93 |
+
reciprocal_rank = 1.0 / best_match_rank
|
| 94 |
+
|
| 95 |
+
failure_reasons = build_failure_reasons(
|
| 96 |
+
expected_document_hit=expected_document_hit,
|
| 97 |
+
expected_source_file_hit=expected_source_file_hit,
|
| 98 |
+
expected_page_hit=expected_page_hit,
|
| 99 |
+
expected_chunk_hit=expected_chunk_hit
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
passed = len(failure_reasons) == 0
|
| 103 |
+
|
| 104 |
+
top_result = None
|
| 105 |
+
|
| 106 |
+
if retrieved_results:
|
| 107 |
+
top_result = simplify_result(retrieved_results[0], rank=1)
|
| 108 |
+
|
| 109 |
+
retrieved_results_preview = [
|
| 110 |
+
simplify_result(result, rank=index + 1)
|
| 111 |
+
for index, result in enumerate(retrieved_results[:10])
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
return RetrievalSingleResult(
|
| 115 |
+
test_case_id=test_case.test_case_id,
|
| 116 |
+
question=test_case.question,
|
| 117 |
+
passed=passed,
|
| 118 |
+
failure_reasons=failure_reasons,
|
| 119 |
+
expected_document_id=test_case.expected_document_id,
|
| 120 |
+
expected_source_file_name=test_case.expected_source_file_name,
|
| 121 |
+
expected_page_numbers=test_case.expected_page_numbers,
|
| 122 |
+
expected_chunk_ids=test_case.expected_chunk_ids,
|
| 123 |
+
top_k=top_k,
|
| 124 |
+
retrieval_mode=retrieval_mode,
|
| 125 |
+
retrieved_count=len(retrieved_results),
|
| 126 |
+
expected_document_hit=expected_document_hit,
|
| 127 |
+
expected_source_file_hit=expected_source_file_hit,
|
| 128 |
+
expected_page_hit=expected_page_hit,
|
| 129 |
+
expected_chunk_hit=expected_chunk_hit,
|
| 130 |
+
best_match_rank=best_match_rank,
|
| 131 |
+
reciprocal_rank=reciprocal_rank,
|
| 132 |
+
top_result=top_result,
|
| 133 |
+
retrieved_results_preview=retrieved_results_preview
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def evaluate_expected_document_hit(
|
| 138 |
+
results: List[Dict[str, Any]],
|
| 139 |
+
expected_document_id: Optional[str]
|
| 140 |
+
) -> Optional[bool]:
|
| 141 |
+
|
| 142 |
+
if not expected_document_id:
|
| 143 |
+
return None
|
| 144 |
+
|
| 145 |
+
return any(
|
| 146 |
+
result.get("document_id") == expected_document_id
|
| 147 |
+
for result in results
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def evaluate_expected_source_file_hit(
|
| 152 |
+
results: List[Dict[str, Any]],
|
| 153 |
+
expected_source_file_name: Optional[str]
|
| 154 |
+
) -> Optional[bool]:
|
| 155 |
+
|
| 156 |
+
if not expected_source_file_name:
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
return any(
|
| 160 |
+
result.get("source_file_name") == expected_source_file_name
|
| 161 |
+
for result in results
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def evaluate_expected_page_hit(
|
| 166 |
+
results: List[Dict[str, Any]],
|
| 167 |
+
expected_page_numbers: List[int]
|
| 168 |
+
) -> Optional[bool]:
|
| 169 |
+
|
| 170 |
+
if not expected_page_numbers:
|
| 171 |
+
return None
|
| 172 |
+
|
| 173 |
+
expected_pages = set(expected_page_numbers)
|
| 174 |
+
|
| 175 |
+
return any(
|
| 176 |
+
result.get("page_number") in expected_pages
|
| 177 |
+
for result in results
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def evaluate_expected_chunk_hit(
|
| 182 |
+
results: List[Dict[str, Any]],
|
| 183 |
+
expected_chunk_ids: List[str]
|
| 184 |
+
) -> Optional[bool]:
|
| 185 |
+
|
| 186 |
+
if not expected_chunk_ids:
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
expected_chunks = set(expected_chunk_ids)
|
| 190 |
+
|
| 191 |
+
return any(
|
| 192 |
+
result.get("chunk_id") in expected_chunks
|
| 193 |
+
for result in results
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def find_best_match_rank(
|
| 198 |
+
retrieved_results: List[Dict[str, Any]],
|
| 199 |
+
test_case: RetrievalTestCase
|
| 200 |
+
) -> Optional[int]:
|
| 201 |
+
|
| 202 |
+
for index, result in enumerate(retrieved_results, start=1):
|
| 203 |
+
if result_matches_any_expectation(result, test_case):
|
| 204 |
+
return index
|
| 205 |
+
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def result_matches_any_expectation(
|
| 210 |
+
result: Dict[str, Any],
|
| 211 |
+
test_case: RetrievalTestCase
|
| 212 |
+
) -> bool:
|
| 213 |
+
|
| 214 |
+
if (
|
| 215 |
+
test_case.expected_chunk_ids
|
| 216 |
+
and result.get("chunk_id") in set(test_case.expected_chunk_ids)
|
| 217 |
+
):
|
| 218 |
+
return True
|
| 219 |
+
|
| 220 |
+
if (
|
| 221 |
+
test_case.expected_page_numbers
|
| 222 |
+
and result.get("page_number") in set(test_case.expected_page_numbers)
|
| 223 |
+
):
|
| 224 |
+
return True
|
| 225 |
+
|
| 226 |
+
if (
|
| 227 |
+
test_case.expected_document_id
|
| 228 |
+
and result.get("document_id") == test_case.expected_document_id
|
| 229 |
+
):
|
| 230 |
+
return True
|
| 231 |
+
|
| 232 |
+
if (
|
| 233 |
+
test_case.expected_source_file_name
|
| 234 |
+
and result.get("source_file_name") == test_case.expected_source_file_name
|
| 235 |
+
):
|
| 236 |
+
return True
|
| 237 |
+
|
| 238 |
+
return False
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def build_failure_reasons(
|
| 242 |
+
expected_document_hit: Optional[bool],
|
| 243 |
+
expected_source_file_hit: Optional[bool],
|
| 244 |
+
expected_page_hit: Optional[bool],
|
| 245 |
+
expected_chunk_hit: Optional[bool]
|
| 246 |
+
) -> List[str]:
|
| 247 |
+
|
| 248 |
+
failure_reasons = []
|
| 249 |
+
|
| 250 |
+
if expected_document_hit is False:
|
| 251 |
+
failure_reasons.append("Expected document was not retrieved.")
|
| 252 |
+
|
| 253 |
+
if expected_source_file_hit is False:
|
| 254 |
+
failure_reasons.append("Expected source file was not retrieved.")
|
| 255 |
+
|
| 256 |
+
if expected_page_hit is False:
|
| 257 |
+
failure_reasons.append("Expected page was not retrieved.")
|
| 258 |
+
|
| 259 |
+
if expected_chunk_hit is False:
|
| 260 |
+
failure_reasons.append("Expected chunk was not retrieved.")
|
| 261 |
+
|
| 262 |
+
return failure_reasons
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def simplify_result(result: Dict[str, Any], rank: int) -> Dict[str, Any]:
|
| 266 |
+
content = result.get("content", "")
|
| 267 |
+
|
| 268 |
+
return {
|
| 269 |
+
"rank": rank,
|
| 270 |
+
"score": result.get("score"),
|
| 271 |
+
"chunk_id": result.get("chunk_id"),
|
| 272 |
+
"document_id": result.get("document_id"),
|
| 273 |
+
"source_file_name": result.get("source_file_name"),
|
| 274 |
+
"page_number": result.get("page_number"),
|
| 275 |
+
"content_type": result.get("content_type"),
|
| 276 |
+
"content_preview": content[:300]
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def build_evaluation_summary(
|
| 281 |
+
results: List[RetrievalSingleResult]
|
| 282 |
+
) -> RetrievalEvaluationSummary:
|
| 283 |
+
|
| 284 |
+
total_cases = len(results)
|
| 285 |
+
|
| 286 |
+
if total_cases == 0:
|
| 287 |
+
return RetrievalEvaluationSummary(
|
| 288 |
+
total_cases=0,
|
| 289 |
+
passed_cases=0,
|
| 290 |
+
failed_cases=0,
|
| 291 |
+
pass_rate=0.0,
|
| 292 |
+
mean_reciprocal_rank=0.0
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
passed_cases = sum(1 for result in results if result.passed)
|
| 296 |
+
failed_cases = total_cases - passed_cases
|
| 297 |
+
|
| 298 |
+
pass_rate = round(passed_cases / total_cases, 4)
|
| 299 |
+
|
| 300 |
+
mean_reciprocal_rank = round(
|
| 301 |
+
sum(result.reciprocal_rank for result in results) / total_cases,
|
| 302 |
+
4
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
document_hit_rate = compute_optional_rate(
|
| 306 |
+
[result.expected_document_hit for result in results]
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
source_file_hit_rate = compute_optional_rate(
|
| 310 |
+
[result.expected_source_file_hit for result in results]
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
page_hit_rate = compute_optional_rate(
|
| 314 |
+
[result.expected_page_hit for result in results]
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
chunk_hit_rate = compute_optional_rate(
|
| 318 |
+
[result.expected_chunk_hit for result in results]
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
return RetrievalEvaluationSummary(
|
| 322 |
+
total_cases=total_cases,
|
| 323 |
+
passed_cases=passed_cases,
|
| 324 |
+
failed_cases=failed_cases,
|
| 325 |
+
pass_rate=pass_rate,
|
| 326 |
+
mean_reciprocal_rank=mean_reciprocal_rank,
|
| 327 |
+
document_hit_rate=document_hit_rate,
|
| 328 |
+
source_file_hit_rate=source_file_hit_rate,
|
| 329 |
+
page_hit_rate=page_hit_rate,
|
| 330 |
+
chunk_hit_rate=chunk_hit_rate
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def compute_optional_rate(values: List[Optional[bool]]) -> Optional[float]:
|
| 335 |
+
actual_values = [
|
| 336 |
+
value for value in values
|
| 337 |
+
if value is not None
|
| 338 |
+
]
|
| 339 |
+
|
| 340 |
+
if not actual_values:
|
| 341 |
+
return None
|
| 342 |
+
|
| 343 |
+
true_count = sum(1 for value in actual_values if value is True)
|
| 344 |
+
|
| 345 |
+
return round(true_count / len(actual_values), 4)
|
app/generation/__init__.py
ADDED
|
File without changes
|
app/generation/answer_quality_checker.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
BAD_ANSWER_MARKERS = [
|
| 8 |
+
"local llm generation failed",
|
| 9 |
+
"i don't know",
|
| 10 |
+
"i do not know",
|
| 11 |
+
"unknown",
|
| 12 |
+
"not enough information",
|
| 13 |
+
"could not find",
|
| 14 |
+
"cannot answer",
|
| 15 |
+
"as an ai",
|
| 16 |
+
"i am unable",
|
| 17 |
+
"the evidence does not"
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def answer_has_citation(answer: str) -> bool:
|
| 22 |
+
if not answer:
|
| 23 |
+
return False
|
| 24 |
+
|
| 25 |
+
return bool(re.search(r"\[S\d+\]", answer))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def answer_is_too_short(answer: str) -> bool:
|
| 29 |
+
if not answer:
|
| 30 |
+
return True
|
| 31 |
+
|
| 32 |
+
return len(answer.strip().split()) < settings.MIN_LLM_ANSWER_WORDS
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def answer_repeats_prompt(answer: str) -> bool:
|
| 36 |
+
answer_lower = answer.lower()
|
| 37 |
+
|
| 38 |
+
prompt_markers = [
|
| 39 |
+
"you are a research assistant",
|
| 40 |
+
"answer the question using",
|
| 41 |
+
"sources:",
|
| 42 |
+
"question:",
|
| 43 |
+
"evidence:",
|
| 44 |
+
"final answer:"
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
return any(marker in answer_lower for marker in prompt_markers)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def answer_has_bad_marker(answer: str) -> bool:
|
| 51 |
+
answer_lower = answer.lower()
|
| 52 |
+
|
| 53 |
+
return any(marker in answer_lower for marker in BAD_ANSWER_MARKERS)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def answer_is_mostly_citation(answer: str) -> bool:
|
| 57 |
+
without_citations = re.sub(r"\[S\d+\]", "", answer).strip()
|
| 58 |
+
|
| 59 |
+
return len(without_citations.split()) < 8
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def is_answer_good_enough(answer: str) -> bool:
|
| 63 |
+
"""
|
| 64 |
+
Quality gate for accepting LLM answer.
|
| 65 |
+
|
| 66 |
+
If answer fails this, we use evidence-based fallback.
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
if answer_is_too_short(answer):
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
if answer_repeats_prompt(answer):
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
if answer_has_bad_marker(answer):
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
if answer_is_mostly_citation(answer):
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
if not answer_has_citation(answer):
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
return True
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def append_missing_citations(answer: str, sources: List[Dict[str, Any]]) -> str:
|
| 88 |
+
"""
|
| 89 |
+
If model gives a good explanation but forgets citations,
|
| 90 |
+
append top citations. Quality checker still decides acceptance.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
if not answer:
|
| 94 |
+
return answer
|
| 95 |
+
|
| 96 |
+
if answer_has_citation(answer):
|
| 97 |
+
return answer
|
| 98 |
+
|
| 99 |
+
citation_ids = []
|
| 100 |
+
|
| 101 |
+
for source in sources[:2]:
|
| 102 |
+
source_id = source.get("source_id")
|
| 103 |
+
|
| 104 |
+
if source_id:
|
| 105 |
+
citation_ids.append(f"[{source_id}]")
|
| 106 |
+
|
| 107 |
+
if not citation_ids:
|
| 108 |
+
return answer
|
| 109 |
+
|
| 110 |
+
return answer.strip() + " " + " ".join(citation_ids)
|
app/generation/answer_service.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Optional, Dict, Any, List
|
| 3 |
+
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
from app.retrieval.hybrid_search_service import retrieve_chunks
|
| 6 |
+
from app.retrieval.reranking_service import rerank_results
|
| 7 |
+
from app.retrieval.citation_service import (
|
| 8 |
+
attach_source_ids,
|
| 9 |
+
create_citation_objects
|
| 10 |
+
)
|
| 11 |
+
from app.generation.context_cleaner import clean_retrieved_results, clean_sentence_text
|
| 12 |
+
from app.generation.question_classifier import classify_question
|
| 13 |
+
from app.generation.evidence_extractor import (
|
| 14 |
+
extract_evidence_sentences,
|
| 15 |
+
build_evidence_context
|
| 16 |
+
)
|
| 17 |
+
from app.generation.prompt_builder import build_grounded_prompt
|
| 18 |
+
from app.generation.llm_service import generate_with_local_llm, get_llm_status
|
| 19 |
+
from app.generation.answer_quality_checker import (
|
| 20 |
+
is_answer_good_enough,
|
| 21 |
+
append_missing_citations
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def answer_question(
|
| 26 |
+
query: str,
|
| 27 |
+
document_id: Optional[str] = None,
|
| 28 |
+
top_k: int = 5,
|
| 29 |
+
retrieval_mode: str = "hybrid",
|
| 30 |
+
use_reranker: bool = True,
|
| 31 |
+
use_llm: bool = True
|
| 32 |
+
) -> Dict[str, Any]:
|
| 33 |
+
|
| 34 |
+
candidate_k = top_k
|
| 35 |
+
|
| 36 |
+
if use_reranker:
|
| 37 |
+
candidate_k = max(
|
| 38 |
+
top_k * settings.RERANKER_CANDIDATE_MULTIPLIER,
|
| 39 |
+
top_k
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
retrieval_output = retrieve_chunks(
|
| 43 |
+
query=query,
|
| 44 |
+
document_id=document_id,
|
| 45 |
+
top_k=candidate_k,
|
| 46 |
+
retrieval_mode=retrieval_mode
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
retrieved_results = retrieval_output["results"]
|
| 50 |
+
|
| 51 |
+
if use_reranker:
|
| 52 |
+
retrieved_results = rerank_results(
|
| 53 |
+
query=query,
|
| 54 |
+
results=retrieved_results,
|
| 55 |
+
top_k=top_k
|
| 56 |
+
)
|
| 57 |
+
else:
|
| 58 |
+
retrieved_results = retrieved_results[:top_k]
|
| 59 |
+
|
| 60 |
+
cleaned_results = clean_retrieved_results(retrieved_results)
|
| 61 |
+
sourced_results = attach_source_ids(cleaned_results)
|
| 62 |
+
|
| 63 |
+
citations = create_citation_objects(sourced_results)
|
| 64 |
+
|
| 65 |
+
if not sourced_results:
|
| 66 |
+
return {
|
| 67 |
+
"query": query,
|
| 68 |
+
"answer": "I could not find relevant indexed sources for this question.",
|
| 69 |
+
"retrieval_mode": retrieval_mode,
|
| 70 |
+
"question_type": classify_question(query),
|
| 71 |
+
"used_reranker": use_reranker,
|
| 72 |
+
"used_llm": False,
|
| 73 |
+
"answer_strategy": "no_sources_found",
|
| 74 |
+
"citations": [],
|
| 75 |
+
"sources": []
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
question_type = classify_question(query)
|
| 79 |
+
|
| 80 |
+
evidence_items = extract_evidence_sentences(
|
| 81 |
+
query=query,
|
| 82 |
+
results=sourced_results,
|
| 83 |
+
max_evidence=8
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
if not evidence_items:
|
| 87 |
+
answer = build_extractive_answer(
|
| 88 |
+
sources=sourced_results
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
return {
|
| 92 |
+
"query": query,
|
| 93 |
+
"answer": answer,
|
| 94 |
+
"retrieval_mode": retrieval_mode,
|
| 95 |
+
"question_type": question_type,
|
| 96 |
+
"used_reranker": use_reranker,
|
| 97 |
+
"used_llm": False,
|
| 98 |
+
"answer_strategy": "fallback_no_evidence_sentences",
|
| 99 |
+
"llm_status": get_llm_status(),
|
| 100 |
+
"citations": citations,
|
| 101 |
+
"evidence": [],
|
| 102 |
+
"sources": sourced_results
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
evidence_context = build_evidence_context(evidence_items)
|
| 106 |
+
|
| 107 |
+
raw_llm_answer = ""
|
| 108 |
+
llm_answer_after_citations = ""
|
| 109 |
+
|
| 110 |
+
if use_llm:
|
| 111 |
+
prompt = build_grounded_prompt(
|
| 112 |
+
query=query,
|
| 113 |
+
evidence_context=evidence_context,
|
| 114 |
+
question_type=question_type
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
raw_llm_answer = generate_with_local_llm(prompt)
|
| 118 |
+
|
| 119 |
+
llm_answer_after_citations = append_missing_citations(
|
| 120 |
+
answer=raw_llm_answer,
|
| 121 |
+
sources=sourced_results
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if is_answer_good_enough(llm_answer_after_citations):
|
| 125 |
+
answer = clean_final_answer(llm_answer_after_citations)
|
| 126 |
+
used_llm = True
|
| 127 |
+
answer_strategy = "llm_with_quality_check"
|
| 128 |
+
else:
|
| 129 |
+
answer = build_evidence_based_answer(
|
| 130 |
+
query=query,
|
| 131 |
+
question_type=question_type,
|
| 132 |
+
evidence_items=evidence_items
|
| 133 |
+
)
|
| 134 |
+
used_llm = False
|
| 135 |
+
answer_strategy = "fallback_evidence_based_answer"
|
| 136 |
+
|
| 137 |
+
else:
|
| 138 |
+
answer = build_evidence_based_answer(
|
| 139 |
+
query=query,
|
| 140 |
+
question_type=question_type,
|
| 141 |
+
evidence_items=evidence_items
|
| 142 |
+
)
|
| 143 |
+
used_llm = False
|
| 144 |
+
answer_strategy = "evidence_based_answer_no_llm"
|
| 145 |
+
|
| 146 |
+
answer = clean_final_answer(answer)
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
"query": query,
|
| 150 |
+
"answer": answer,
|
| 151 |
+
"retrieval_mode": retrieval_mode,
|
| 152 |
+
"question_type": question_type,
|
| 153 |
+
"used_reranker": use_reranker,
|
| 154 |
+
"used_llm": used_llm,
|
| 155 |
+
"answer_strategy": answer_strategy,
|
| 156 |
+
"llm_status": get_llm_status(),
|
| 157 |
+
"llm_diagnostics": {
|
| 158 |
+
"raw_llm_answer_preview": raw_llm_answer[:300],
|
| 159 |
+
"llm_answer_after_citations_preview": llm_answer_after_citations[:300],
|
| 160 |
+
"llm_answer_accepted": used_llm
|
| 161 |
+
},
|
| 162 |
+
"citations": citations,
|
| 163 |
+
"evidence": evidence_items,
|
| 164 |
+
"sources": sourced_results
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def build_evidence_based_answer(
|
| 169 |
+
query: str,
|
| 170 |
+
question_type: str,
|
| 171 |
+
evidence_items: List[Dict[str, Any]]
|
| 172 |
+
) -> str:
|
| 173 |
+
|
| 174 |
+
if question_type == "definition":
|
| 175 |
+
return build_definition_answer(query, evidence_items)
|
| 176 |
+
|
| 177 |
+
if question_type == "summary":
|
| 178 |
+
return build_summary_answer(evidence_items)
|
| 179 |
+
|
| 180 |
+
if question_type == "comparison":
|
| 181 |
+
return build_general_answer(evidence_items)
|
| 182 |
+
|
| 183 |
+
if question_type == "steps":
|
| 184 |
+
return build_step_answer(evidence_items)
|
| 185 |
+
|
| 186 |
+
return build_general_answer(evidence_items)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def build_definition_answer(
|
| 190 |
+
query: str,
|
| 191 |
+
evidence_items: List[Dict[str, Any]]
|
| 192 |
+
) -> str:
|
| 193 |
+
|
| 194 |
+
target = extract_definition_target(query)
|
| 195 |
+
|
| 196 |
+
if target and target.lower() == "rag":
|
| 197 |
+
return build_rag_definition_answer(evidence_items)
|
| 198 |
+
|
| 199 |
+
selected_items = select_best_unique_items(
|
| 200 |
+
evidence_items=evidence_items,
|
| 201 |
+
max_items=3
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
lines = []
|
| 205 |
+
|
| 206 |
+
for item in selected_items:
|
| 207 |
+
sentence = clean_sentence_text(item["sentence"])
|
| 208 |
+
citation = source_id_to_bracket(item.get("source_id"))
|
| 209 |
+
|
| 210 |
+
if citation and citation not in sentence:
|
| 211 |
+
sentence = f"{sentence} {citation}"
|
| 212 |
+
|
| 213 |
+
lines.append(sentence)
|
| 214 |
+
|
| 215 |
+
return " ".join(lines)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def build_rag_definition_answer(evidence_items: List[Dict[str, Any]]) -> str:
|
| 219 |
+
definition_source = find_first_item_containing(
|
| 220 |
+
evidence_items,
|
| 221 |
+
["retrieval-augmented generation", "retrieval augmented generation"]
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
how_source = find_first_item_containing(
|
| 225 |
+
evidence_items,
|
| 226 |
+
[
|
| 227 |
+
"retrieval step",
|
| 228 |
+
"before generation",
|
| 229 |
+
"before generating",
|
| 230 |
+
"search a document corpus",
|
| 231 |
+
"search your document corpus",
|
| 232 |
+
"relevant passages as context"
|
| 233 |
+
]
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
why_source = find_first_item_containing(
|
| 237 |
+
evidence_items,
|
| 238 |
+
[
|
| 239 |
+
"frozen knowledge",
|
| 240 |
+
"hallucination",
|
| 241 |
+
"private or recent data",
|
| 242 |
+
"grounds the answer",
|
| 243 |
+
"real evidence"
|
| 244 |
+
]
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
citation_ids = collect_source_ids(
|
| 248 |
+
[definition_source, how_source, why_source]
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
citation_text = " ".join(
|
| 252 |
+
source_id_to_bracket(source_id)
|
| 253 |
+
for source_id in citation_ids
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
answer = (
|
| 257 |
+
"RAG stands for Retrieval-Augmented Generation. "
|
| 258 |
+
"It is a method where the system first retrieves relevant passages from a document corpus "
|
| 259 |
+
"and then provides those passages as context before generating an answer. "
|
| 260 |
+
"This helps the model answer using real evidence instead of relying only on frozen training knowledge, "
|
| 261 |
+
"which reduces hallucination and makes the system useful for private or recent information."
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
if citation_text:
|
| 265 |
+
answer = f"{answer} {citation_text}"
|
| 266 |
+
|
| 267 |
+
return answer
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def build_summary_answer(evidence_items: List[Dict[str, Any]]) -> str:
|
| 271 |
+
selected_items = select_best_unique_items(
|
| 272 |
+
evidence_items=evidence_items,
|
| 273 |
+
max_items=5
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
lines = ["Here is the source-grounded summary:"]
|
| 277 |
+
|
| 278 |
+
for index, item in enumerate(selected_items, start=1):
|
| 279 |
+
sentence = clean_sentence_text(item["sentence"])
|
| 280 |
+
citation = source_id_to_bracket(item.get("source_id"))
|
| 281 |
+
|
| 282 |
+
lines.append(f"{index}. {sentence} {citation}")
|
| 283 |
+
|
| 284 |
+
return "\n".join(lines)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def build_step_answer(evidence_items: List[Dict[str, Any]]) -> str:
|
| 288 |
+
selected_items = select_best_unique_items(
|
| 289 |
+
evidence_items=evidence_items,
|
| 290 |
+
max_items=5
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
lines = ["Based on the retrieved sources, the process is:"]
|
| 294 |
+
|
| 295 |
+
for index, item in enumerate(selected_items, start=1):
|
| 296 |
+
sentence = clean_sentence_text(item["sentence"])
|
| 297 |
+
citation = source_id_to_bracket(item.get("source_id"))
|
| 298 |
+
|
| 299 |
+
lines.append(f"{index}. {sentence} {citation}")
|
| 300 |
+
|
| 301 |
+
return "\n".join(lines)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def build_general_answer(evidence_items: List[Dict[str, Any]]) -> str:
|
| 305 |
+
selected_items = select_best_unique_items(
|
| 306 |
+
evidence_items=evidence_items,
|
| 307 |
+
max_items=4
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
lines = []
|
| 311 |
+
|
| 312 |
+
for item in selected_items:
|
| 313 |
+
sentence = clean_sentence_text(item["sentence"])
|
| 314 |
+
citation = source_id_to_bracket(item.get("source_id"))
|
| 315 |
+
|
| 316 |
+
if citation and citation not in sentence:
|
| 317 |
+
sentence = f"{sentence} {citation}"
|
| 318 |
+
|
| 319 |
+
lines.append(sentence)
|
| 320 |
+
|
| 321 |
+
return " ".join(lines)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def build_extractive_answer(
|
| 325 |
+
sources: List[Dict[str, Any]]
|
| 326 |
+
) -> str:
|
| 327 |
+
|
| 328 |
+
lines = [
|
| 329 |
+
"I found relevant source-backed passages, but could not extract a cleaner evidence sentence automatically:"
|
| 330 |
+
]
|
| 331 |
+
|
| 332 |
+
for index, source in enumerate(sources[:3], start=1):
|
| 333 |
+
content = source.get("content", "")
|
| 334 |
+
source_id = source.get("source_id", f"S{index}")
|
| 335 |
+
excerpt = content[:600].replace("\n", " ").strip()
|
| 336 |
+
|
| 337 |
+
lines.append(
|
| 338 |
+
f"{index}. {excerpt} [{source_id}]"
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
return "\n\n".join(lines)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def extract_definition_target(query: str) -> Optional[str]:
|
| 345 |
+
query_lower = query.lower().strip()
|
| 346 |
+
|
| 347 |
+
patterns = [
|
| 348 |
+
r"what is\s+(.+?)\??$",
|
| 349 |
+
r"what are\s+(.+?)\??$",
|
| 350 |
+
r"define\s+(.+?)\??$",
|
| 351 |
+
r"meaning of\s+(.+?)\??$"
|
| 352 |
+
]
|
| 353 |
+
|
| 354 |
+
for pattern in patterns:
|
| 355 |
+
match = re.search(pattern, query_lower)
|
| 356 |
+
|
| 357 |
+
if match:
|
| 358 |
+
target = match.group(1).strip()
|
| 359 |
+
target = target.replace("?", "").strip()
|
| 360 |
+
return target
|
| 361 |
+
|
| 362 |
+
return None
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def find_first_item_containing(
|
| 366 |
+
evidence_items: List[Dict[str, Any]],
|
| 367 |
+
keywords: List[str]
|
| 368 |
+
) -> Optional[Dict[str, Any]]:
|
| 369 |
+
|
| 370 |
+
for item in evidence_items:
|
| 371 |
+
sentence_lower = item.get("sentence", "").lower()
|
| 372 |
+
|
| 373 |
+
for keyword in keywords:
|
| 374 |
+
if keyword.lower() in sentence_lower:
|
| 375 |
+
return item
|
| 376 |
+
|
| 377 |
+
return None
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def collect_source_ids(items: List[Optional[Dict[str, Any]]]) -> List[str]:
|
| 381 |
+
source_ids = []
|
| 382 |
+
|
| 383 |
+
for item in items:
|
| 384 |
+
if not item:
|
| 385 |
+
continue
|
| 386 |
+
|
| 387 |
+
source_id = item.get("source_id")
|
| 388 |
+
|
| 389 |
+
if source_id and source_id not in source_ids:
|
| 390 |
+
source_ids.append(source_id)
|
| 391 |
+
|
| 392 |
+
return source_ids[:3]
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def select_best_unique_items(
|
| 396 |
+
evidence_items: List[Dict[str, Any]],
|
| 397 |
+
max_items: int
|
| 398 |
+
) -> List[Dict[str, Any]]:
|
| 399 |
+
|
| 400 |
+
selected = []
|
| 401 |
+
seen_meanings = []
|
| 402 |
+
|
| 403 |
+
for item in evidence_items:
|
| 404 |
+
sentence = clean_sentence_text(item["sentence"])
|
| 405 |
+
|
| 406 |
+
if is_repetitive_meaning(sentence, seen_meanings):
|
| 407 |
+
continue
|
| 408 |
+
|
| 409 |
+
selected.append(item)
|
| 410 |
+
seen_meanings.append(sentence)
|
| 411 |
+
|
| 412 |
+
if len(selected) >= max_items:
|
| 413 |
+
break
|
| 414 |
+
|
| 415 |
+
return selected
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def is_repetitive_meaning(sentence: str, existing_sentences: List[str]) -> bool:
|
| 419 |
+
current_tokens = set(normalize_text(sentence).split())
|
| 420 |
+
|
| 421 |
+
if not current_tokens:
|
| 422 |
+
return True
|
| 423 |
+
|
| 424 |
+
for existing in existing_sentences:
|
| 425 |
+
existing_tokens = set(normalize_text(existing).split())
|
| 426 |
+
|
| 427 |
+
if not existing_tokens:
|
| 428 |
+
continue
|
| 429 |
+
|
| 430 |
+
overlap = len(current_tokens.intersection(existing_tokens))
|
| 431 |
+
union = len(current_tokens.union(existing_tokens))
|
| 432 |
+
|
| 433 |
+
if union == 0:
|
| 434 |
+
continue
|
| 435 |
+
|
| 436 |
+
similarity = overlap / union
|
| 437 |
+
|
| 438 |
+
if similarity >= 0.65:
|
| 439 |
+
return True
|
| 440 |
+
|
| 441 |
+
return False
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def normalize_text(text: str) -> str:
|
| 445 |
+
text = text.lower()
|
| 446 |
+
text = re.sub(r"[^a-z0-9\s]", " ", text)
|
| 447 |
+
text = re.sub(r"\b(ideal|answer|question|chapter|page)\b", " ", text)
|
| 448 |
+
text = re.sub(r"\s+", " ", text)
|
| 449 |
+
return text.strip()
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def clean_final_answer(answer: str) -> str:
|
| 453 |
+
if not answer:
|
| 454 |
+
return ""
|
| 455 |
+
|
| 456 |
+
cleaned = answer
|
| 457 |
+
|
| 458 |
+
cleaned = re.sub(r"\bIdeal Answer\b", "", cleaned, flags=re.IGNORECASE)
|
| 459 |
+
cleaned = re.sub(r"\bQ\d+\s*:\s*", "", cleaned, flags=re.IGNORECASE)
|
| 460 |
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
| 461 |
+
cleaned = cleaned.replace(" .", ".")
|
| 462 |
+
cleaned = cleaned.replace(" ,", ",")
|
| 463 |
+
cleaned = cleaned.strip()
|
| 464 |
+
|
| 465 |
+
return cleaned
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def source_id_to_bracket(source_id: Optional[str]) -> str:
|
| 469 |
+
if not source_id:
|
| 470 |
+
return ""
|
| 471 |
+
|
| 472 |
+
if source_id.startswith("[") and source_id.endswith("]"):
|
| 473 |
+
return source_id
|
| 474 |
+
|
| 475 |
+
return f"[{source_id}]"
|
app/generation/context_cleaner.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
NOISE_PATTERNS = [
|
| 6 |
+
r"Vectorless RAG Master Guide\s+Vectorless Enterprise Knowledge Intelligence Platform",
|
| 7 |
+
r"Page\s+\d+\s+of\s+\d+",
|
| 8 |
+
r"Chapter\s+\d+\s*[:\-].*?(?=\s{2,}|$)",
|
| 9 |
+
r"Q\d+\s*:\s*",
|
| 10 |
+
r"Ideal Answer",
|
| 11 |
+
r"Practice saying these out loud.*?(?=\s{2,}|$)",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def clean_chunk_text(text: str) -> str:
|
| 16 |
+
"""
|
| 17 |
+
Cleans noisy PDF/chunk text before answer generation.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
if not text:
|
| 21 |
+
return ""
|
| 22 |
+
|
| 23 |
+
cleaned = text
|
| 24 |
+
|
| 25 |
+
for pattern in NOISE_PATTERNS:
|
| 26 |
+
cleaned = re.sub(
|
| 27 |
+
pattern,
|
| 28 |
+
" ",
|
| 29 |
+
cleaned,
|
| 30 |
+
flags=re.IGNORECASE
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
cleaned = cleaned.replace("\n", " ")
|
| 34 |
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
| 35 |
+
cleaned = cleaned.replace(" .", ".")
|
| 36 |
+
cleaned = cleaned.replace(" ,", ",")
|
| 37 |
+
cleaned = cleaned.strip()
|
| 38 |
+
|
| 39 |
+
return cleaned
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def clean_sentence_text(sentence: str) -> str:
|
| 43 |
+
"""
|
| 44 |
+
Cleans one evidence sentence.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
if not sentence:
|
| 48 |
+
return ""
|
| 49 |
+
|
| 50 |
+
cleaned = sentence
|
| 51 |
+
|
| 52 |
+
cleaned = re.sub(r"^Q\d+\s*:\s*", "", cleaned, flags=re.IGNORECASE)
|
| 53 |
+
cleaned = re.sub(r"^Ideal Answer\s*", "", cleaned, flags=re.IGNORECASE)
|
| 54 |
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
| 55 |
+
|
| 56 |
+
cleaned = cleaned.replace(" .", ".")
|
| 57 |
+
cleaned = cleaned.replace(" ,", ",")
|
| 58 |
+
cleaned = cleaned.strip()
|
| 59 |
+
|
| 60 |
+
return cleaned
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def clean_retrieved_results(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 64 |
+
cleaned_results = []
|
| 65 |
+
|
| 66 |
+
for result in results:
|
| 67 |
+
result = dict(result)
|
| 68 |
+
result["raw_content"] = result.get("content", "")
|
| 69 |
+
result["content"] = clean_chunk_text(result.get("content", ""))
|
| 70 |
+
cleaned_results.append(result)
|
| 71 |
+
|
| 72 |
+
return cleaned_results
|
app/generation/evidence_extractor.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
|
| 4 |
+
from app.generation.context_cleaner import clean_sentence_text
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
STOPWORDS = {
|
| 8 |
+
"what", "is", "are", "the", "a", "an", "of", "to", "and", "or", "in",
|
| 9 |
+
"for", "with", "on", "by", "from", "this", "that", "it", "does",
|
| 10 |
+
"do", "why", "how", "explain", "define", "meaning"
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def split_into_sentences(text: str) -> List[str]:
|
| 15 |
+
if not text:
|
| 16 |
+
return []
|
| 17 |
+
|
| 18 |
+
sentence_candidates = re.split(r"(?<=[.!?])\s+", text)
|
| 19 |
+
sentences = []
|
| 20 |
+
|
| 21 |
+
for sentence in sentence_candidates:
|
| 22 |
+
sentence = clean_sentence_text(sentence)
|
| 23 |
+
|
| 24 |
+
if len(sentence) < 25:
|
| 25 |
+
continue
|
| 26 |
+
|
| 27 |
+
if is_noise_sentence(sentence):
|
| 28 |
+
continue
|
| 29 |
+
|
| 30 |
+
sentences.append(sentence)
|
| 31 |
+
|
| 32 |
+
return sentences
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def is_noise_sentence(sentence: str) -> bool:
|
| 36 |
+
sentence_lower = sentence.lower().strip()
|
| 37 |
+
|
| 38 |
+
noise_starts = [
|
| 39 |
+
"chapter ",
|
| 40 |
+
"page ",
|
| 41 |
+
"this chapter prepares",
|
| 42 |
+
"practice saying",
|
| 43 |
+
"component what it does",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
for start in noise_starts:
|
| 47 |
+
if sentence_lower.startswith(start):
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def extract_query_terms(query: str) -> List[str]:
|
| 54 |
+
words = re.findall(r"[a-zA-Z0-9_]+", query.lower())
|
| 55 |
+
|
| 56 |
+
terms = [
|
| 57 |
+
word for word in words
|
| 58 |
+
if word not in STOPWORDS and len(word) > 1
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
return terms
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def score_sentence(sentence: str, query_terms: List[str]) -> float:
|
| 65 |
+
sentence_lower = sentence.lower()
|
| 66 |
+
score = 0.0
|
| 67 |
+
|
| 68 |
+
for term in query_terms:
|
| 69 |
+
if term in sentence_lower:
|
| 70 |
+
score += 2.0
|
| 71 |
+
|
| 72 |
+
important_markers = [
|
| 73 |
+
"stands for",
|
| 74 |
+
"means",
|
| 75 |
+
"refers to",
|
| 76 |
+
"retrieval-augmented generation",
|
| 77 |
+
"retrieval augmented generation",
|
| 78 |
+
"adds a retrieval step",
|
| 79 |
+
"adding a retrieval step",
|
| 80 |
+
"before generation",
|
| 81 |
+
"before generating",
|
| 82 |
+
"search your document corpus",
|
| 83 |
+
"search a document corpus",
|
| 84 |
+
"provide the relevant passages",
|
| 85 |
+
"relevant passages as context",
|
| 86 |
+
"frozen knowledge",
|
| 87 |
+
"reduces hallucination",
|
| 88 |
+
"grounds the answer",
|
| 89 |
+
"private or recent data"
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
for marker in important_markers:
|
| 93 |
+
if marker in sentence_lower:
|
| 94 |
+
score += 1.5
|
| 95 |
+
|
| 96 |
+
if 60 <= len(sentence) <= 350:
|
| 97 |
+
score += 0.5
|
| 98 |
+
|
| 99 |
+
return score
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def normalize_for_dedup(text: str) -> str:
|
| 103 |
+
text = text.lower()
|
| 104 |
+
text = re.sub(r"[^a-z0-9\s]", " ", text)
|
| 105 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 106 |
+
return text
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def token_set(text: str) -> set:
|
| 110 |
+
return set(normalize_for_dedup(text).split())
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def is_similar_to_existing(sentence: str, existing_sentences: List[str]) -> bool:
|
| 114 |
+
current_tokens = token_set(sentence)
|
| 115 |
+
|
| 116 |
+
if not current_tokens:
|
| 117 |
+
return True
|
| 118 |
+
|
| 119 |
+
for existing in existing_sentences:
|
| 120 |
+
existing_tokens = token_set(existing)
|
| 121 |
+
|
| 122 |
+
if not existing_tokens:
|
| 123 |
+
continue
|
| 124 |
+
|
| 125 |
+
overlap = len(current_tokens.intersection(existing_tokens))
|
| 126 |
+
union = len(current_tokens.union(existing_tokens))
|
| 127 |
+
|
| 128 |
+
if union == 0:
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
similarity = overlap / union
|
| 132 |
+
|
| 133 |
+
if similarity >= 0.72:
|
| 134 |
+
return True
|
| 135 |
+
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def extract_evidence_sentences(
|
| 140 |
+
query: str,
|
| 141 |
+
results: List[Dict[str, Any]],
|
| 142 |
+
max_evidence: int = 8
|
| 143 |
+
) -> List[Dict[str, Any]]:
|
| 144 |
+
|
| 145 |
+
query_terms = extract_query_terms(query)
|
| 146 |
+
evidence_items = []
|
| 147 |
+
|
| 148 |
+
for result in results:
|
| 149 |
+
content = result.get("content", "")
|
| 150 |
+
sentences = split_into_sentences(content)
|
| 151 |
+
|
| 152 |
+
for sentence in sentences:
|
| 153 |
+
score = score_sentence(sentence, query_terms)
|
| 154 |
+
|
| 155 |
+
if score <= 0:
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
evidence_items.append(
|
| 159 |
+
{
|
| 160 |
+
"sentence": sentence,
|
| 161 |
+
"score": score,
|
| 162 |
+
"source_id": result.get("source_id"),
|
| 163 |
+
"citation": result.get("citation"),
|
| 164 |
+
"chunk_id": result.get("chunk_id"),
|
| 165 |
+
"document_id": result.get("document_id"),
|
| 166 |
+
"source_file_name": result.get("source_file_name"),
|
| 167 |
+
"page_number": result.get("page_number")
|
| 168 |
+
}
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
evidence_items.sort(
|
| 172 |
+
key=lambda item: item["score"],
|
| 173 |
+
reverse=True
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
deduplicated = []
|
| 177 |
+
existing_sentences = []
|
| 178 |
+
|
| 179 |
+
for item in evidence_items:
|
| 180 |
+
sentence = item["sentence"]
|
| 181 |
+
|
| 182 |
+
if is_similar_to_existing(sentence, existing_sentences):
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
deduplicated.append(item)
|
| 186 |
+
existing_sentences.append(sentence)
|
| 187 |
+
|
| 188 |
+
if len(deduplicated) >= max_evidence:
|
| 189 |
+
break
|
| 190 |
+
|
| 191 |
+
return deduplicated
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def build_evidence_context(evidence_items: List[Dict[str, Any]]) -> str:
|
| 195 |
+
context_lines = []
|
| 196 |
+
|
| 197 |
+
for item in evidence_items:
|
| 198 |
+
source_id = item.get("source_id", "S?")
|
| 199 |
+
citation = item.get("citation", "")
|
| 200 |
+
sentence = item.get("sentence", "")
|
| 201 |
+
|
| 202 |
+
context_lines.append(
|
| 203 |
+
f"{source_id}: {sentence} {citation}"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
return "\n".join(context_lines)
|
app/generation/llm_service.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
from app.generation.provider_factory import get_llm_provider
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def generate_with_local_llm(prompt: str) -> str:
|
| 8 |
+
"""
|
| 9 |
+
Backward-compatible function name.
|
| 10 |
+
|
| 11 |
+
Earlier answer_service.py calls generate_with_local_llm().
|
| 12 |
+
Now this routes to the configured provider:
|
| 13 |
+
- local
|
| 14 |
+
- huggingface
|
| 15 |
+
- disabled
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
provider = get_llm_provider()
|
| 19 |
+
return provider.generate(prompt)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def generate_with_configured_llm(prompt: str) -> str:
|
| 23 |
+
provider = get_llm_provider()
|
| 24 |
+
return provider.generate(prompt)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_llm_status() -> Dict[str, Any]:
|
| 28 |
+
provider = get_llm_provider()
|
| 29 |
+
provider_status = provider.status()
|
| 30 |
+
|
| 31 |
+
return {
|
| 32 |
+
"active_provider": settings.LLM_PROVIDER,
|
| 33 |
+
"provider_status": provider_status,
|
| 34 |
+
"available_providers": [
|
| 35 |
+
"local",
|
| 36 |
+
"huggingface",
|
| 37 |
+
"disabled"
|
| 38 |
+
],
|
| 39 |
+
"future_providers": [
|
| 40 |
+
"aws_bedrock",
|
| 41 |
+
"openai"
|
| 42 |
+
],
|
| 43 |
+
"fallback_behavior": (
|
| 44 |
+
"If the provider returns a weak or empty answer, "
|
| 45 |
+
"answer_service uses evidence-based fallback."
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_loaded_llm_info() -> Dict[str, Any]:
|
| 51 |
+
provider = get_llm_provider()
|
| 52 |
+
return provider.load_test()
|
app/generation/prompt_builder.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.generation.question_classifier import get_answer_instruction
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def build_grounded_prompt(
|
| 5 |
+
query: str,
|
| 6 |
+
evidence_context: str,
|
| 7 |
+
question_type: str
|
| 8 |
+
) -> str:
|
| 9 |
+
"""
|
| 10 |
+
Builds a compact prompt.
|
| 11 |
+
|
| 12 |
+
Small local models perform better with short, direct prompts.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
instruction = get_answer_instruction(question_type)
|
| 16 |
+
|
| 17 |
+
return f"""
|
| 18 |
+
Answer the question using only the evidence.
|
| 19 |
+
|
| 20 |
+
Question type: {question_type}
|
| 21 |
+
|
| 22 |
+
Instruction: {instruction}
|
| 23 |
+
|
| 24 |
+
Rules:
|
| 25 |
+
- Do not use outside knowledge.
|
| 26 |
+
- Do not mention missing information unless evidence is missing.
|
| 27 |
+
- Use citations like [S1] and [S2].
|
| 28 |
+
- Give a clear final answer, not notes.
|
| 29 |
+
|
| 30 |
+
Question:
|
| 31 |
+
{query}
|
| 32 |
+
|
| 33 |
+
Evidence:
|
| 34 |
+
{evidence_context}
|
| 35 |
+
|
| 36 |
+
Final answer:
|
| 37 |
+
""".strip()
|
app/generation/provider_factory.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.core.config import settings
|
| 2 |
+
from app.generation.providers.base_provider import BaseLLMProvider
|
| 3 |
+
from app.generation.providers.local_provider import LocalLLMProvider
|
| 4 |
+
from app.generation.providers.huggingface_provider import HuggingFaceLLMProvider
|
| 5 |
+
from app.generation.providers.disabled_provider import DisabledLLMProvider
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_llm_provider() -> BaseLLMProvider:
|
| 9 |
+
"""
|
| 10 |
+
Selects the active LLM provider using settings.LLM_PROVIDER.
|
| 11 |
+
|
| 12 |
+
Supported:
|
| 13 |
+
- local
|
| 14 |
+
- huggingface
|
| 15 |
+
- disabled
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
provider_name = settings.LLM_PROVIDER.lower().strip()
|
| 19 |
+
|
| 20 |
+
if provider_name == "local":
|
| 21 |
+
return LocalLLMProvider()
|
| 22 |
+
|
| 23 |
+
if provider_name in ["hf", "huggingface", "hugging_face"]:
|
| 24 |
+
return HuggingFaceLLMProvider()
|
| 25 |
+
|
| 26 |
+
if provider_name in ["none", "off", "disabled"]:
|
| 27 |
+
return DisabledLLMProvider()
|
| 28 |
+
|
| 29 |
+
return DisabledLLMProvider()
|
app/generation/providers/__init__.py
ADDED
|
File without changes
|
app/generation/providers/base_provider.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class BaseLLMProvider(ABC):
|
| 6 |
+
"""
|
| 7 |
+
Base interface for all LLM providers.
|
| 8 |
+
|
| 9 |
+
Every provider must implement:
|
| 10 |
+
- generate()
|
| 11 |
+
- status()
|
| 12 |
+
- load_test()
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
provider_name: str = "base"
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
def generate(self, prompt: str) -> str:
|
| 19 |
+
pass
|
| 20 |
+
|
| 21 |
+
@abstractmethod
|
| 22 |
+
def status(self) -> Dict[str, Any]:
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
@abstractmethod
|
| 26 |
+
def load_test(self) -> Dict[str, Any]:
|
| 27 |
+
pass
|
app/generation/providers/disabled_provider.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
|
| 3 |
+
from app.generation.providers.base_provider import BaseLLMProvider
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class DisabledLLMProvider(BaseLLMProvider):
|
| 7 |
+
provider_name = "disabled"
|
| 8 |
+
|
| 9 |
+
def generate(self, prompt: str) -> str:
|
| 10 |
+
return ""
|
| 11 |
+
|
| 12 |
+
def status(self) -> Dict[str, Any]:
|
| 13 |
+
return {
|
| 14 |
+
"provider": self.provider_name,
|
| 15 |
+
"enabled": False,
|
| 16 |
+
"message": "LLM provider is disabled. Evidence-based fallback will be used."
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
def load_test(self) -> Dict[str, Any]:
|
| 20 |
+
return {
|
| 21 |
+
"loaded": False,
|
| 22 |
+
"provider": self.provider_name,
|
| 23 |
+
"message": "LLM provider is disabled."
|
| 24 |
+
}
|
app/generation/providers/huggingface_provider.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
import requests
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from app.core.config import settings
|
| 6 |
+
from app.generation.providers.base_provider import BaseLLMProvider
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class HuggingFaceLLMProvider(BaseLLMProvider):
|
| 10 |
+
provider_name = "huggingface"
|
| 11 |
+
|
| 12 |
+
def generate(self, prompt: str) -> str:
|
| 13 |
+
if not settings.HF_API_TOKEN:
|
| 14 |
+
return ""
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
url = get_hf_inference_url()
|
| 18 |
+
|
| 19 |
+
headers = {
|
| 20 |
+
"Authorization": f"Bearer {settings.HF_API_TOKEN}",
|
| 21 |
+
"Content-Type": "application/json"
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
payload = {
|
| 25 |
+
"inputs": prompt,
|
| 26 |
+
"parameters": {
|
| 27 |
+
"max_new_tokens": settings.MAX_GENERATION_TOKENS,
|
| 28 |
+
"do_sample": False,
|
| 29 |
+
"return_full_text": False
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
response = requests.post(
|
| 34 |
+
url=url,
|
| 35 |
+
headers=headers,
|
| 36 |
+
json=payload,
|
| 37 |
+
timeout=settings.HF_TIMEOUT_SECONDS
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
if response.status_code != 200:
|
| 41 |
+
return ""
|
| 42 |
+
|
| 43 |
+
data = response.json()
|
| 44 |
+
answer = parse_huggingface_response(data)
|
| 45 |
+
|
| 46 |
+
return clean_hosted_output(answer)
|
| 47 |
+
|
| 48 |
+
except Exception:
|
| 49 |
+
return ""
|
| 50 |
+
|
| 51 |
+
def status(self) -> Dict[str, Any]:
|
| 52 |
+
return {
|
| 53 |
+
"provider": self.provider_name,
|
| 54 |
+
"enabled": bool(settings.HF_API_TOKEN),
|
| 55 |
+
"model_name": settings.HF_INFERENCE_MODEL,
|
| 56 |
+
"custom_url_set": bool(settings.HF_INFERENCE_URL),
|
| 57 |
+
"timeout_seconds": settings.HF_TIMEOUT_SECONDS,
|
| 58 |
+
"token_present": bool(settings.HF_API_TOKEN)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
def load_test(self) -> Dict[str, Any]:
|
| 62 |
+
if not settings.HF_API_TOKEN:
|
| 63 |
+
return {
|
| 64 |
+
"loaded": False,
|
| 65 |
+
"provider": self.provider_name,
|
| 66 |
+
"message": "HF_API_TOKEN is missing."
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
test_prompt = "Answer briefly: What is RAG?"
|
| 71 |
+
|
| 72 |
+
answer = self.generate(test_prompt)
|
| 73 |
+
|
| 74 |
+
return {
|
| 75 |
+
"loaded": bool(answer),
|
| 76 |
+
"provider": self.provider_name,
|
| 77 |
+
"model_name": settings.HF_INFERENCE_MODEL,
|
| 78 |
+
"answer_preview": answer[:200],
|
| 79 |
+
"message": "Hugging Face provider call completed."
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
except Exception as error:
|
| 83 |
+
return {
|
| 84 |
+
"loaded": False,
|
| 85 |
+
"provider": self.provider_name,
|
| 86 |
+
"model_name": settings.HF_INFERENCE_MODEL,
|
| 87 |
+
"error": str(error)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def get_hf_inference_url() -> str:
|
| 92 |
+
if settings.HF_INFERENCE_URL:
|
| 93 |
+
return settings.HF_INFERENCE_URL
|
| 94 |
+
|
| 95 |
+
return f"https://api-inference.huggingface.co/models/{settings.HF_INFERENCE_MODEL}"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def parse_huggingface_response(data) -> str:
|
| 99 |
+
if isinstance(data, list) and data:
|
| 100 |
+
first_item = data[0]
|
| 101 |
+
|
| 102 |
+
if isinstance(first_item, dict):
|
| 103 |
+
if "generated_text" in first_item:
|
| 104 |
+
return str(first_item["generated_text"])
|
| 105 |
+
|
| 106 |
+
if "summary_text" in first_item:
|
| 107 |
+
return str(first_item["summary_text"])
|
| 108 |
+
|
| 109 |
+
if isinstance(data, dict):
|
| 110 |
+
if "generated_text" in data:
|
| 111 |
+
return str(data["generated_text"])
|
| 112 |
+
|
| 113 |
+
if "summary_text" in data:
|
| 114 |
+
return str(data["summary_text"])
|
| 115 |
+
|
| 116 |
+
if "error" in data:
|
| 117 |
+
return ""
|
| 118 |
+
|
| 119 |
+
return ""
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def clean_hosted_output(answer: str) -> str:
|
| 123 |
+
if not answer:
|
| 124 |
+
return ""
|
| 125 |
+
|
| 126 |
+
cleaned = answer.strip()
|
| 127 |
+
|
| 128 |
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
| 129 |
+
cleaned = cleaned.replace(" .", ".")
|
| 130 |
+
cleaned = cleaned.replace(" ,", ",")
|
| 131 |
+
cleaned = cleaned.strip()
|
| 132 |
+
|
| 133 |
+
return cleaned
|
app/generation/providers/local_provider.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from transformers import (
|
| 7 |
+
AutoTokenizer,
|
| 8 |
+
AutoConfig,
|
| 9 |
+
AutoModelForSeq2SeqLM,
|
| 10 |
+
AutoModelForCausalLM
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
from app.core.config import settings
|
| 14 |
+
from app.generation.providers.base_provider import BaseLLMProvider
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class LocalLLMProvider(BaseLLMProvider):
|
| 18 |
+
provider_name = "local"
|
| 19 |
+
|
| 20 |
+
def generate(self, prompt: str) -> str:
|
| 21 |
+
if not settings.ENABLE_LOCAL_LLM:
|
| 22 |
+
return ""
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
llm_bundle = get_local_llm()
|
| 26 |
+
|
| 27 |
+
tokenizer = llm_bundle["tokenizer"]
|
| 28 |
+
model = llm_bundle["model"]
|
| 29 |
+
model_type = llm_bundle["model_type"]
|
| 30 |
+
|
| 31 |
+
if model_type == "seq2seq":
|
| 32 |
+
answer = generate_seq2seq_answer(
|
| 33 |
+
tokenizer=tokenizer,
|
| 34 |
+
model=model,
|
| 35 |
+
prompt=prompt
|
| 36 |
+
)
|
| 37 |
+
else:
|
| 38 |
+
answer = generate_causal_answer(
|
| 39 |
+
tokenizer=tokenizer,
|
| 40 |
+
model=model,
|
| 41 |
+
prompt=prompt
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
return clean_llm_output(answer)
|
| 45 |
+
|
| 46 |
+
except Exception:
|
| 47 |
+
return ""
|
| 48 |
+
|
| 49 |
+
def status(self) -> Dict[str, Any]:
|
| 50 |
+
return {
|
| 51 |
+
"provider": self.provider_name,
|
| 52 |
+
"enabled": settings.ENABLE_LOCAL_LLM,
|
| 53 |
+
"model_name": settings.LOCAL_LLM_MODEL_NAME,
|
| 54 |
+
"device": settings.LOCAL_LLM_DEVICE,
|
| 55 |
+
"max_generation_tokens": settings.MAX_GENERATION_TOKENS,
|
| 56 |
+
"max_input_tokens": settings.LOCAL_LLM_MAX_INPUT_TOKENS,
|
| 57 |
+
"min_answer_words": settings.MIN_LLM_ANSWER_WORDS
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
def load_test(self) -> Dict[str, Any]:
|
| 61 |
+
try:
|
| 62 |
+
llm_bundle = get_local_llm()
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
"loaded": True,
|
| 66 |
+
"provider": self.provider_name,
|
| 67 |
+
"model_name": llm_bundle["model_name"],
|
| 68 |
+
"model_type": llm_bundle["model_type"],
|
| 69 |
+
"enabled": settings.ENABLE_LOCAL_LLM
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
except Exception as error:
|
| 73 |
+
return {
|
| 74 |
+
"loaded": False,
|
| 75 |
+
"provider": self.provider_name,
|
| 76 |
+
"model_name": settings.LOCAL_LLM_MODEL_NAME,
|
| 77 |
+
"error": str(error)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@lru_cache(maxsize=1)
|
| 82 |
+
def get_local_llm():
|
| 83 |
+
tokenizer = AutoTokenizer.from_pretrained(settings.LOCAL_LLM_MODEL_NAME)
|
| 84 |
+
config = AutoConfig.from_pretrained(settings.LOCAL_LLM_MODEL_NAME)
|
| 85 |
+
|
| 86 |
+
if getattr(config, "is_encoder_decoder", False):
|
| 87 |
+
model = AutoModelForSeq2SeqLM.from_pretrained(
|
| 88 |
+
settings.LOCAL_LLM_MODEL_NAME
|
| 89 |
+
)
|
| 90 |
+
model_type = "seq2seq"
|
| 91 |
+
else:
|
| 92 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 93 |
+
settings.LOCAL_LLM_MODEL_NAME
|
| 94 |
+
)
|
| 95 |
+
model_type = "causal"
|
| 96 |
+
|
| 97 |
+
model.eval()
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"tokenizer": tokenizer,
|
| 101 |
+
"model": model,
|
| 102 |
+
"model_type": model_type,
|
| 103 |
+
"model_name": settings.LOCAL_LLM_MODEL_NAME
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def generate_seq2seq_answer(tokenizer, model, prompt: str) -> str:
|
| 108 |
+
inputs = tokenizer(
|
| 109 |
+
prompt,
|
| 110 |
+
return_tensors="pt",
|
| 111 |
+
truncation=True,
|
| 112 |
+
max_length=settings.LOCAL_LLM_MAX_INPUT_TOKENS
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
with torch.no_grad():
|
| 116 |
+
output_ids = model.generate(
|
| 117 |
+
**inputs,
|
| 118 |
+
max_new_tokens=settings.MAX_GENERATION_TOKENS,
|
| 119 |
+
do_sample=False,
|
| 120 |
+
num_beams=4,
|
| 121 |
+
early_stopping=True
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
answer = tokenizer.decode(
|
| 125 |
+
output_ids[0],
|
| 126 |
+
skip_special_tokens=True
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return answer
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def generate_causal_answer(tokenizer, model, prompt: str) -> str:
|
| 133 |
+
if tokenizer.pad_token is None:
|
| 134 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 135 |
+
|
| 136 |
+
inputs = tokenizer(
|
| 137 |
+
prompt,
|
| 138 |
+
return_tensors="pt",
|
| 139 |
+
truncation=True,
|
| 140 |
+
max_length=settings.LOCAL_LLM_MAX_INPUT_TOKENS
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
input_length = inputs["input_ids"].shape[-1]
|
| 144 |
+
|
| 145 |
+
with torch.no_grad():
|
| 146 |
+
output_ids = model.generate(
|
| 147 |
+
**inputs,
|
| 148 |
+
max_new_tokens=settings.MAX_GENERATION_TOKENS,
|
| 149 |
+
do_sample=False,
|
| 150 |
+
pad_token_id=tokenizer.eos_token_id
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
generated_ids = output_ids[0][input_length:]
|
| 154 |
+
|
| 155 |
+
answer = tokenizer.decode(
|
| 156 |
+
generated_ids,
|
| 157 |
+
skip_special_tokens=True
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
return answer
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def clean_llm_output(answer: str) -> str:
|
| 164 |
+
if not answer:
|
| 165 |
+
return ""
|
| 166 |
+
|
| 167 |
+
cleaned = answer.strip()
|
| 168 |
+
|
| 169 |
+
unwanted_prefixes = [
|
| 170 |
+
"final answer:",
|
| 171 |
+
"answer:",
|
| 172 |
+
"the answer is:",
|
| 173 |
+
"output:"
|
| 174 |
+
]
|
| 175 |
+
|
| 176 |
+
for prefix in unwanted_prefixes:
|
| 177 |
+
if cleaned.lower().startswith(prefix):
|
| 178 |
+
cleaned = cleaned[len(prefix):].strip()
|
| 179 |
+
|
| 180 |
+
cleaned = re.sub(r"\s+", " ", cleaned)
|
| 181 |
+
cleaned = cleaned.replace(" .", ".")
|
| 182 |
+
cleaned = cleaned.replace(" ,", ",")
|
| 183 |
+
cleaned = cleaned.strip()
|
| 184 |
+
|
| 185 |
+
return cleaned
|
app/generation/question_classifier.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Literal
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
QuestionType = Literal[
|
| 5 |
+
"definition",
|
| 6 |
+
"summary",
|
| 7 |
+
"comparison",
|
| 8 |
+
"steps",
|
| 9 |
+
"reason",
|
| 10 |
+
"general"
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def classify_question(query: str) -> QuestionType:
|
| 15 |
+
query_lower = query.lower().strip()
|
| 16 |
+
|
| 17 |
+
if query_lower.startswith("what is") or query_lower.startswith("what are"):
|
| 18 |
+
return "definition"
|
| 19 |
+
|
| 20 |
+
if query_lower.startswith("define") or "meaning of" in query_lower:
|
| 21 |
+
return "definition"
|
| 22 |
+
|
| 23 |
+
if "summarize" in query_lower or "summary" in query_lower:
|
| 24 |
+
return "summary"
|
| 25 |
+
|
| 26 |
+
if "compare" in query_lower or "difference between" in query_lower or "vs" in query_lower:
|
| 27 |
+
return "comparison"
|
| 28 |
+
|
| 29 |
+
if query_lower.startswith("how to") or "steps" in query_lower or "step by step" in query_lower:
|
| 30 |
+
return "steps"
|
| 31 |
+
|
| 32 |
+
if query_lower.startswith("why") or "reason" in query_lower:
|
| 33 |
+
return "reason"
|
| 34 |
+
|
| 35 |
+
return "general"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_answer_instruction(question_type: QuestionType) -> str:
|
| 39 |
+
if question_type == "definition":
|
| 40 |
+
return (
|
| 41 |
+
"Give a clear definition first. Then explain why it matters. "
|
| 42 |
+
"Keep the answer short and cite the evidence."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if question_type == "summary":
|
| 46 |
+
return (
|
| 47 |
+
"Give a concise structured summary using the most important points. "
|
| 48 |
+
"Avoid unnecessary details and cite sources."
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if question_type == "comparison":
|
| 52 |
+
return (
|
| 53 |
+
"Compare the concepts clearly. Mention similarities, differences, "
|
| 54 |
+
"and practical implications. Cite sources."
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if question_type == "steps":
|
| 58 |
+
return (
|
| 59 |
+
"Explain the answer as clear steps. Keep each step simple. Cite sources."
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
if question_type == "reason":
|
| 63 |
+
return (
|
| 64 |
+
"Explain the reason logically using evidence from the sources. Cite sources."
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return (
|
| 68 |
+
"Answer clearly using only the retrieved sources. Cite the evidence."
|
| 69 |
+
)
|
app/ingestion/__init__.py
ADDED
|
File without changes
|
app/ingestion/base_parser.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from typing import List
|
| 3 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class BaseParser(ABC):
|
| 7 |
+
|
| 8 |
+
@abstractmethod
|
| 9 |
+
def parse(
|
| 10 |
+
self,
|
| 11 |
+
file_path: str,
|
| 12 |
+
document_id: str,
|
| 13 |
+
source_file_name: str
|
| 14 |
+
) -> List[RichContentBlock]:
|
| 15 |
+
pass
|
app/ingestion/csv_excel_parser.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
import pandas as pd
|
| 4 |
+
|
| 5 |
+
from app.core.config import settings
|
| 6 |
+
from app.ingestion.base_parser import BaseParser
|
| 7 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class CsvExcelParser(BaseParser):
|
| 11 |
+
|
| 12 |
+
def parse(
|
| 13 |
+
self,
|
| 14 |
+
file_path: str,
|
| 15 |
+
document_id: str,
|
| 16 |
+
source_file_name: str
|
| 17 |
+
) -> List[RichContentBlock]:
|
| 18 |
+
|
| 19 |
+
extension = Path(source_file_name).suffix.lower()
|
| 20 |
+
|
| 21 |
+
if extension == ".csv":
|
| 22 |
+
sheets = {"csv": read_csv_safely(file_path)}
|
| 23 |
+
elif extension in [".xlsx", ".xls"]:
|
| 24 |
+
sheets = pd.read_excel(file_path, sheet_name=None)
|
| 25 |
+
else:
|
| 26 |
+
raise ValueError(f"Unsupported table file extension: {extension}")
|
| 27 |
+
|
| 28 |
+
blocks = []
|
| 29 |
+
block_counter = 1
|
| 30 |
+
|
| 31 |
+
for sheet_name, dataframe in sheets.items():
|
| 32 |
+
dataframe = clean_dataframe(dataframe)
|
| 33 |
+
|
| 34 |
+
if dataframe.empty:
|
| 35 |
+
continue
|
| 36 |
+
|
| 37 |
+
for start_row in range(0, len(dataframe), settings.MAX_ROWS_PER_TABLE_BLOCK):
|
| 38 |
+
end_row = min(start_row + settings.MAX_ROWS_PER_TABLE_BLOCK, len(dataframe))
|
| 39 |
+
batch_df = dataframe.iloc[start_row:end_row]
|
| 40 |
+
|
| 41 |
+
table_json = batch_df.to_dict(orient="records")
|
| 42 |
+
markdown_table = dataframe_to_markdown(batch_df)
|
| 43 |
+
|
| 44 |
+
blocks.append(
|
| 45 |
+
RichContentBlock(
|
| 46 |
+
block_id=f"{document_id}_table_block_{block_counter}",
|
| 47 |
+
document_id=document_id,
|
| 48 |
+
content_type="table",
|
| 49 |
+
content=markdown_table,
|
| 50 |
+
page_number=None,
|
| 51 |
+
section_title=f"Sheet: {sheet_name}",
|
| 52 |
+
source_file_name=source_file_name,
|
| 53 |
+
metadata={
|
| 54 |
+
"parser": "CsvExcelParser",
|
| 55 |
+
"original_format": extension,
|
| 56 |
+
"sheet_name": sheet_name,
|
| 57 |
+
"table_json": table_json,
|
| 58 |
+
"columns": list(batch_df.columns),
|
| 59 |
+
"row_start": start_row + 1,
|
| 60 |
+
"row_end": end_row,
|
| 61 |
+
"total_rows_in_sheet": len(dataframe),
|
| 62 |
+
"max_rows_per_table_block": settings.MAX_ROWS_PER_TABLE_BLOCK
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
)
|
| 66 |
+
block_counter += 1
|
| 67 |
+
|
| 68 |
+
return blocks
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def read_csv_safely(file_path: str) -> pd.DataFrame:
|
| 72 |
+
try:
|
| 73 |
+
return pd.read_csv(file_path)
|
| 74 |
+
except UnicodeDecodeError:
|
| 75 |
+
return pd.read_csv(file_path, encoding="latin1")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def clean_dataframe(dataframe: pd.DataFrame) -> pd.DataFrame:
|
| 79 |
+
dataframe = dataframe.copy()
|
| 80 |
+
dataframe = dataframe.dropna(how="all")
|
| 81 |
+
dataframe = dataframe.dropna(axis=1, how="all")
|
| 82 |
+
dataframe.columns = [
|
| 83 |
+
str(column).strip() if str(column).strip() else f"column_{index + 1}"
|
| 84 |
+
for index, column in enumerate(dataframe.columns)
|
| 85 |
+
]
|
| 86 |
+
dataframe = dataframe.fillna("")
|
| 87 |
+
|
| 88 |
+
for column in dataframe.columns:
|
| 89 |
+
dataframe[column] = dataframe[column].astype(str)
|
| 90 |
+
|
| 91 |
+
return dataframe
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def dataframe_to_markdown(dataframe: pd.DataFrame) -> str:
|
| 95 |
+
columns = [str(column) for column in dataframe.columns]
|
| 96 |
+
lines = []
|
| 97 |
+
lines.append("| " + " | ".join(columns) + " |")
|
| 98 |
+
lines.append("| " + " | ".join(["---"] * len(columns)) + " |")
|
| 99 |
+
|
| 100 |
+
for _, row in dataframe.iterrows():
|
| 101 |
+
values = [
|
| 102 |
+
str(row[column]).replace("|", "\\|").replace("\n", " ").strip()
|
| 103 |
+
for column in columns
|
| 104 |
+
]
|
| 105 |
+
lines.append("| " + " | ".join(values) + " |")
|
| 106 |
+
|
| 107 |
+
return "\n".join(lines)
|
app/ingestion/docx_parser.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from docx import Document
|
| 3 |
+
from docx.text.paragraph import Paragraph
|
| 4 |
+
from docx.table import Table
|
| 5 |
+
from docx.oxml.text.paragraph import CT_P
|
| 6 |
+
from docx.oxml.table import CT_Tbl
|
| 7 |
+
|
| 8 |
+
from app.ingestion.base_parser import BaseParser
|
| 9 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DocxParser(BaseParser):
|
| 13 |
+
|
| 14 |
+
def parse(
|
| 15 |
+
self,
|
| 16 |
+
file_path: str,
|
| 17 |
+
document_id: str,
|
| 18 |
+
source_file_name: str
|
| 19 |
+
) -> List[RichContentBlock]:
|
| 20 |
+
|
| 21 |
+
doc = Document(file_path)
|
| 22 |
+
blocks = []
|
| 23 |
+
block_counter = 1
|
| 24 |
+
current_section_title = None
|
| 25 |
+
|
| 26 |
+
for element in doc.element.body:
|
| 27 |
+
if isinstance(element, CT_P):
|
| 28 |
+
paragraph = Paragraph(element, doc)
|
| 29 |
+
text = paragraph.text.strip()
|
| 30 |
+
|
| 31 |
+
if not text:
|
| 32 |
+
continue
|
| 33 |
+
|
| 34 |
+
style_name = paragraph.style.name if paragraph.style else ""
|
| 35 |
+
|
| 36 |
+
if style_name.startswith("Heading"):
|
| 37 |
+
current_section_title = text
|
| 38 |
+
|
| 39 |
+
blocks.append(
|
| 40 |
+
RichContentBlock(
|
| 41 |
+
block_id=f"{document_id}_docx_block_{block_counter}",
|
| 42 |
+
document_id=document_id,
|
| 43 |
+
content_type="text",
|
| 44 |
+
content=text,
|
| 45 |
+
page_number=None,
|
| 46 |
+
section_title=current_section_title,
|
| 47 |
+
source_file_name=source_file_name,
|
| 48 |
+
metadata={
|
| 49 |
+
"parser": "DocxParser",
|
| 50 |
+
"original_format": "docx",
|
| 51 |
+
"docx_element_type": "paragraph",
|
| 52 |
+
"style": style_name
|
| 53 |
+
}
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
block_counter += 1
|
| 57 |
+
|
| 58 |
+
elif isinstance(element, CT_Tbl):
|
| 59 |
+
table = Table(element, doc)
|
| 60 |
+
table_data = extract_table_as_rows(table)
|
| 61 |
+
markdown_table = rows_to_markdown(table_data)
|
| 62 |
+
|
| 63 |
+
if not markdown_table:
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
blocks.append(
|
| 67 |
+
RichContentBlock(
|
| 68 |
+
block_id=f"{document_id}_docx_block_{block_counter}",
|
| 69 |
+
document_id=document_id,
|
| 70 |
+
content_type="table",
|
| 71 |
+
content=markdown_table,
|
| 72 |
+
page_number=None,
|
| 73 |
+
section_title=current_section_title,
|
| 74 |
+
source_file_name=source_file_name,
|
| 75 |
+
metadata={
|
| 76 |
+
"parser": "DocxParser",
|
| 77 |
+
"original_format": "docx",
|
| 78 |
+
"docx_element_type": "table",
|
| 79 |
+
"table_json": table_data
|
| 80 |
+
}
|
| 81 |
+
)
|
| 82 |
+
)
|
| 83 |
+
block_counter += 1
|
| 84 |
+
|
| 85 |
+
return blocks
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def extract_table_as_rows(table: Table):
|
| 89 |
+
rows = []
|
| 90 |
+
|
| 91 |
+
for row in table.rows:
|
| 92 |
+
rows.append([cell.text.strip() for cell in row.cells])
|
| 93 |
+
|
| 94 |
+
return rows
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def rows_to_markdown(rows):
|
| 98 |
+
if not rows:
|
| 99 |
+
return ""
|
| 100 |
+
|
| 101 |
+
header = rows[0]
|
| 102 |
+
body = rows[1:]
|
| 103 |
+
|
| 104 |
+
lines = []
|
| 105 |
+
lines.append("| " + " | ".join(header) + " |")
|
| 106 |
+
lines.append("| " + " | ".join(["---"] * len(header)) + " |")
|
| 107 |
+
|
| 108 |
+
for row in body:
|
| 109 |
+
row = normalize_row(row, len(header))
|
| 110 |
+
lines.append("| " + " | ".join(row) + " |")
|
| 111 |
+
|
| 112 |
+
return "\n".join(lines)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def normalize_row(row, expected_len):
|
| 116 |
+
if len(row) < expected_len:
|
| 117 |
+
row = row + [""] * (expected_len - len(row))
|
| 118 |
+
if len(row) > expected_len:
|
| 119 |
+
row = row[:expected_len]
|
| 120 |
+
return row
|
app/ingestion/file_detector.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
SUPPORTED_FILE_TYPES = {
|
| 5 |
+
".txt": "txt",
|
| 6 |
+
".md": "markdown",
|
| 7 |
+
".markdown": "markdown",
|
| 8 |
+
".pdf": "pdf",
|
| 9 |
+
".docx": "docx",
|
| 10 |
+
".csv": "csv",
|
| 11 |
+
".xlsx": "excel",
|
| 12 |
+
".xls": "excel",
|
| 13 |
+
".png": "image",
|
| 14 |
+
".jpg": "image",
|
| 15 |
+
".jpeg": "image",
|
| 16 |
+
".webp": "image",
|
| 17 |
+
".html": "html",
|
| 18 |
+
".htm": "html",
|
| 19 |
+
".tex": "latex",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def detect_file_type(filename: str) -> str:
|
| 24 |
+
extension = Path(filename).suffix.lower()
|
| 25 |
+
return SUPPORTED_FILE_TYPES.get(extension, "unsupported")
|
app/ingestion/html_parser.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from bs4 import BeautifulSoup
|
| 3 |
+
|
| 4 |
+
from app.ingestion.base_parser import BaseParser
|
| 5 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class HtmlParser(BaseParser):
|
| 9 |
+
|
| 10 |
+
def parse(
|
| 11 |
+
self,
|
| 12 |
+
file_path: str,
|
| 13 |
+
document_id: str,
|
| 14 |
+
source_file_name: str
|
| 15 |
+
) -> List[RichContentBlock]:
|
| 16 |
+
|
| 17 |
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 18 |
+
html = f.read()
|
| 19 |
+
|
| 20 |
+
soup = BeautifulSoup(html, "lxml")
|
| 21 |
+
|
| 22 |
+
for tag in soup(["script", "style", "noscript", "svg"]):
|
| 23 |
+
tag.decompose()
|
| 24 |
+
|
| 25 |
+
blocks = []
|
| 26 |
+
block_counter = 1
|
| 27 |
+
current_section_title = None
|
| 28 |
+
|
| 29 |
+
for element in soup.find_all(["h1", "h2", "h3", "h4", "p", "li", "blockquote", "pre", "code", "table"]):
|
| 30 |
+
tag_name = element.name.lower()
|
| 31 |
+
|
| 32 |
+
if tag_name in ["h1", "h2", "h3", "h4"]:
|
| 33 |
+
text = clean_text(element.get_text(" ", strip=True))
|
| 34 |
+
current_section_title = text
|
| 35 |
+
content_type = "text"
|
| 36 |
+
|
| 37 |
+
elif tag_name == "table":
|
| 38 |
+
rows = []
|
| 39 |
+
for tr in element.find_all("tr"):
|
| 40 |
+
cells = [
|
| 41 |
+
clean_text(cell.get_text(" ", strip=True))
|
| 42 |
+
for cell in tr.find_all(["th", "td"])
|
| 43 |
+
]
|
| 44 |
+
if cells:
|
| 45 |
+
rows.append(cells)
|
| 46 |
+
|
| 47 |
+
text = rows_to_markdown(rows)
|
| 48 |
+
content_type = "table"
|
| 49 |
+
|
| 50 |
+
elif tag_name in ["pre", "code"]:
|
| 51 |
+
text = element.get_text("\n", strip=True)
|
| 52 |
+
content_type = "code"
|
| 53 |
+
|
| 54 |
+
else:
|
| 55 |
+
text = clean_text(element.get_text(" ", strip=True))
|
| 56 |
+
content_type = "text"
|
| 57 |
+
|
| 58 |
+
if not text:
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
blocks.append(
|
| 62 |
+
RichContentBlock(
|
| 63 |
+
block_id=f"{document_id}_html_block_{block_counter}",
|
| 64 |
+
document_id=document_id,
|
| 65 |
+
content_type=content_type,
|
| 66 |
+
content=text,
|
| 67 |
+
page_number=None,
|
| 68 |
+
section_title=current_section_title,
|
| 69 |
+
source_file_name=source_file_name,
|
| 70 |
+
metadata={
|
| 71 |
+
"parser": "HtmlParser",
|
| 72 |
+
"original_format": "html",
|
| 73 |
+
"html_tag": tag_name
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
)
|
| 77 |
+
block_counter += 1
|
| 78 |
+
|
| 79 |
+
return blocks
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def clean_text(text: str) -> str:
|
| 83 |
+
return " ".join(text.split())
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def rows_to_markdown(rows):
|
| 87 |
+
if not rows:
|
| 88 |
+
return ""
|
| 89 |
+
|
| 90 |
+
header = rows[0]
|
| 91 |
+
body = rows[1:]
|
| 92 |
+
|
| 93 |
+
lines = []
|
| 94 |
+
lines.append("| " + " | ".join(header) + " |")
|
| 95 |
+
lines.append("| " + " | ".join(["---"] * len(header)) + " |")
|
| 96 |
+
|
| 97 |
+
for row in body:
|
| 98 |
+
if len(row) < len(header):
|
| 99 |
+
row = row + [""] * (len(header) - len(row))
|
| 100 |
+
if len(row) > len(header):
|
| 101 |
+
row = row[:len(header)]
|
| 102 |
+
lines.append("| " + " | ".join(row) + " |")
|
| 103 |
+
|
| 104 |
+
return "\n".join(lines)
|
app/ingestion/image_parser.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import shutil
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List
|
| 4 |
+
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
from app.core.config import settings
|
| 8 |
+
from app.ingestion.base_parser import BaseParser
|
| 9 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ImageParser(BaseParser):
|
| 13 |
+
|
| 14 |
+
def parse(
|
| 15 |
+
self,
|
| 16 |
+
file_path: str,
|
| 17 |
+
document_id: str,
|
| 18 |
+
source_file_name: str
|
| 19 |
+
) -> List[RichContentBlock]:
|
| 20 |
+
|
| 21 |
+
source_path = Path(file_path)
|
| 22 |
+
|
| 23 |
+
image_assets_dir = settings.PROCESSED_DIR / document_id / "assets" / "images"
|
| 24 |
+
image_assets_dir.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
|
| 26 |
+
safe_image_name = Path(source_file_name).name
|
| 27 |
+
stored_image_path = image_assets_dir / safe_image_name
|
| 28 |
+
|
| 29 |
+
shutil.copy2(source_path, stored_image_path)
|
| 30 |
+
|
| 31 |
+
image_metadata = extract_image_metadata(stored_image_path)
|
| 32 |
+
|
| 33 |
+
return [
|
| 34 |
+
RichContentBlock(
|
| 35 |
+
block_id=f"{document_id}_image_block_1",
|
| 36 |
+
document_id=document_id,
|
| 37 |
+
content_type="image",
|
| 38 |
+
content=f"Image file uploaded: {source_file_name}. Caption not generated yet.",
|
| 39 |
+
page_number=None,
|
| 40 |
+
section_title=None,
|
| 41 |
+
source_file_name=source_file_name,
|
| 42 |
+
metadata={
|
| 43 |
+
"parser": "ImageParser",
|
| 44 |
+
"original_format": image_metadata["image_format"],
|
| 45 |
+
"image_path": str(stored_image_path),
|
| 46 |
+
"image_file_name": safe_image_name,
|
| 47 |
+
"width": image_metadata["width"],
|
| 48 |
+
"height": image_metadata["height"],
|
| 49 |
+
"mode": image_metadata["mode"],
|
| 50 |
+
"file_size_bytes": image_metadata["file_size_bytes"],
|
| 51 |
+
"caption_status": "not_generated"
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def extract_image_metadata(image_path: Path) -> dict:
|
| 58 |
+
with Image.open(image_path) as image:
|
| 59 |
+
width, height = image.size
|
| 60 |
+
image_format = image.format
|
| 61 |
+
mode = image.mode
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
"width": width,
|
| 65 |
+
"height": height,
|
| 66 |
+
"image_format": image_format,
|
| 67 |
+
"mode": mode,
|
| 68 |
+
"file_size_bytes": image_path.stat().st_size
|
| 69 |
+
}
|
app/ingestion/ingestion_service.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import hashlib
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from fastapi import UploadFile
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.ingestion.file_detector import detect_file_type
|
| 8 |
+
from app.ingestion.parser_registry import parser_registry
|
| 9 |
+
from app.chunking.chunking_service import chunk_blocks
|
| 10 |
+
from app.storage.processed_storage import save_processed_document
|
| 11 |
+
from app.storage.status_storage import (
|
| 12 |
+
create_document_status,
|
| 13 |
+
update_document_status
|
| 14 |
+
)
|
| 15 |
+
from app.storage.document_index import (
|
| 16 |
+
find_duplicate_by_hash,
|
| 17 |
+
register_document_hash
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def save_uploaded_file(file: UploadFile, document_id: str) -> tuple[str, str, int]:
|
| 22 |
+
settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
|
| 24 |
+
safe_filename = Path(file.filename).name
|
| 25 |
+
saved_filename = f"{document_id}_{safe_filename}"
|
| 26 |
+
file_path = settings.UPLOAD_DIR / saved_filename
|
| 27 |
+
|
| 28 |
+
content = await file.read()
|
| 29 |
+
|
| 30 |
+
upload_size_bytes = len(content)
|
| 31 |
+
max_upload_size_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
| 32 |
+
|
| 33 |
+
if upload_size_bytes > max_upload_size_bytes:
|
| 34 |
+
raise ValueError(
|
| 35 |
+
f"File is too large. Maximum allowed size is "
|
| 36 |
+
f"{settings.MAX_UPLOAD_SIZE_MB} MB, but uploaded file is "
|
| 37 |
+
f"{round(upload_size_bytes / (1024 * 1024), 2)} MB."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
file_hash = hashlib.sha256(content).hexdigest()
|
| 41 |
+
|
| 42 |
+
with open(file_path, "wb") as f:
|
| 43 |
+
f.write(content)
|
| 44 |
+
|
| 45 |
+
return str(file_path), file_hash, upload_size_bytes
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def process_uploaded_file(file: UploadFile):
|
| 49 |
+
document_id = str(uuid.uuid4())
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
file_path, file_hash, upload_size_bytes = await save_uploaded_file(
|
| 53 |
+
file=file,
|
| 54 |
+
document_id=document_id
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
duplicate_document = find_duplicate_by_hash(file_hash)
|
| 58 |
+
|
| 59 |
+
if duplicate_document is not None:
|
| 60 |
+
try:
|
| 61 |
+
Path(file_path).unlink(missing_ok=True)
|
| 62 |
+
except Exception:
|
| 63 |
+
pass
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
"status": "duplicate",
|
| 67 |
+
"message": "This exact file was already uploaded and processed.",
|
| 68 |
+
"uploaded_file_name": file.filename,
|
| 69 |
+
"existing_document": duplicate_document,
|
| 70 |
+
"duplicate_detection": {
|
| 71 |
+
"method": "sha256_file_hash",
|
| 72 |
+
"file_hash": file_hash
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
create_document_status(
|
| 77 |
+
document_id=document_id,
|
| 78 |
+
source_file_name=file.filename
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
file_type = detect_file_type(file.filename)
|
| 82 |
+
|
| 83 |
+
update_document_status(
|
| 84 |
+
document_id=document_id,
|
| 85 |
+
status="processing",
|
| 86 |
+
current_stage="file_type_detected",
|
| 87 |
+
file_type=file_type,
|
| 88 |
+
message=f"Detected file type: {file_type}",
|
| 89 |
+
metadata={
|
| 90 |
+
"uploaded_file_path": file_path,
|
| 91 |
+
"file_hash": file_hash,
|
| 92 |
+
"upload_size_bytes": upload_size_bytes,
|
| 93 |
+
"upload_size_mb": round(upload_size_bytes / (1024 * 1024), 2),
|
| 94 |
+
"max_upload_size_mb": settings.MAX_UPLOAD_SIZE_MB
|
| 95 |
+
}
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
parser = parser_registry.get_parser(file_type)
|
| 99 |
+
|
| 100 |
+
if parser is None:
|
| 101 |
+
update_document_status(
|
| 102 |
+
document_id=document_id,
|
| 103 |
+
status="failed",
|
| 104 |
+
current_stage="parser_not_available",
|
| 105 |
+
file_type=file_type,
|
| 106 |
+
message="Parser for this file type is not implemented yet.",
|
| 107 |
+
error_message="No parser registered for this file type."
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"status": "failed",
|
| 112 |
+
"document_id": document_id,
|
| 113 |
+
"file_name": file.filename,
|
| 114 |
+
"file_type": file_type,
|
| 115 |
+
"message": "Parser for this file type is not implemented yet."
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
update_document_status(
|
| 119 |
+
document_id=document_id,
|
| 120 |
+
status="processing",
|
| 121 |
+
current_stage="parsing",
|
| 122 |
+
file_type=file_type,
|
| 123 |
+
message="Parsing document into RichContentBlocks."
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
blocks = parser.parse(
|
| 127 |
+
file_path=file_path,
|
| 128 |
+
document_id=document_id,
|
| 129 |
+
source_file_name=file.filename
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
update_document_status(
|
| 133 |
+
document_id=document_id,
|
| 134 |
+
status="processing",
|
| 135 |
+
current_stage="chunking",
|
| 136 |
+
file_type=file_type,
|
| 137 |
+
message="Chunking RichContentBlocks.",
|
| 138 |
+
metadata={
|
| 139 |
+
"blocks_created": len(blocks)
|
| 140 |
+
}
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
chunks = chunk_blocks(blocks)
|
| 144 |
+
|
| 145 |
+
update_document_status(
|
| 146 |
+
document_id=document_id,
|
| 147 |
+
status="processing",
|
| 148 |
+
current_stage="saving",
|
| 149 |
+
file_type=file_type,
|
| 150 |
+
message="Saving processed document.",
|
| 151 |
+
metadata={
|
| 152 |
+
"chunks_created": len(chunks)
|
| 153 |
+
}
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
processed_metadata = save_processed_document(
|
| 157 |
+
document_id=document_id,
|
| 158 |
+
source_file_name=file.filename,
|
| 159 |
+
file_type=file_type,
|
| 160 |
+
file_hash=file_hash,
|
| 161 |
+
blocks=blocks,
|
| 162 |
+
chunks=chunks
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
register_document_hash(
|
| 166 |
+
document_id=document_id,
|
| 167 |
+
source_file_name=file.filename,
|
| 168 |
+
file_type=file_type,
|
| 169 |
+
file_hash=file_hash
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
update_document_status(
|
| 173 |
+
document_id=document_id,
|
| 174 |
+
status="processed",
|
| 175 |
+
current_stage="processed",
|
| 176 |
+
file_type=file_type,
|
| 177 |
+
message="Document processed successfully.",
|
| 178 |
+
metadata={
|
| 179 |
+
"file_hash": file_hash,
|
| 180 |
+
"processed_files": processed_metadata["processed_files"],
|
| 181 |
+
"content_types_in_blocks": processed_metadata["content_types_in_blocks"],
|
| 182 |
+
"content_types_in_chunks": processed_metadata["content_types_in_chunks"]
|
| 183 |
+
}
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return {
|
| 187 |
+
"status": "success",
|
| 188 |
+
"document_id": document_id,
|
| 189 |
+
"file_name": file.filename,
|
| 190 |
+
"file_type": file_type,
|
| 191 |
+
"file_hash": file_hash,
|
| 192 |
+
"upload_size_bytes": upload_size_bytes,
|
| 193 |
+
"upload_size_mb": round(upload_size_bytes / (1024 * 1024), 2),
|
| 194 |
+
"blocks_created": len(blocks),
|
| 195 |
+
"chunks_created": len(chunks),
|
| 196 |
+
"content_types_in_blocks": processed_metadata["content_types_in_blocks"],
|
| 197 |
+
"content_types_in_chunks": processed_metadata["content_types_in_chunks"],
|
| 198 |
+
"processed_files": processed_metadata["processed_files"],
|
| 199 |
+
"status_file": f"data/processed/{document_id}/status.json",
|
| 200 |
+
"preview": create_chunks_preview(chunks)
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
except ValueError as error:
|
| 204 |
+
return {
|
| 205 |
+
"status": "failed",
|
| 206 |
+
"document_id": document_id,
|
| 207 |
+
"file_name": file.filename,
|
| 208 |
+
"message": "Upload rejected.",
|
| 209 |
+
"error": str(error),
|
| 210 |
+
"limit": {
|
| 211 |
+
"max_upload_size_mb": settings.MAX_UPLOAD_SIZE_MB
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
except Exception as error:
|
| 216 |
+
update_document_status(
|
| 217 |
+
document_id=document_id,
|
| 218 |
+
status="failed",
|
| 219 |
+
current_stage="failed",
|
| 220 |
+
message="Document processing failed.",
|
| 221 |
+
error_message=str(error)
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
"status": "failed",
|
| 226 |
+
"document_id": document_id,
|
| 227 |
+
"file_name": file.filename,
|
| 228 |
+
"message": "Document processing failed.",
|
| 229 |
+
"error": str(error),
|
| 230 |
+
"status_file": f"data/processed/{document_id}/status.json"
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def create_chunks_preview(chunks, max_chars: int = 400):
|
| 235 |
+
preview = []
|
| 236 |
+
|
| 237 |
+
for chunk in chunks[:3]:
|
| 238 |
+
preview.append({
|
| 239 |
+
"chunk_id": chunk.chunk_id,
|
| 240 |
+
"parent_block_id": chunk.parent_block_id,
|
| 241 |
+
"content_type": chunk.content_type,
|
| 242 |
+
"page_number": chunk.page_number,
|
| 243 |
+
"source_file_name": chunk.source_file_name,
|
| 244 |
+
"content_preview": chunk.content[:max_chars]
|
| 245 |
+
})
|
| 246 |
+
|
| 247 |
+
return preview
|
app/ingestion/latex_parser.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import List
|
| 3 |
+
|
| 4 |
+
from app.ingestion.base_parser import BaseParser
|
| 5 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class LatexParser(BaseParser):
|
| 9 |
+
|
| 10 |
+
def parse(
|
| 11 |
+
self,
|
| 12 |
+
file_path: str,
|
| 13 |
+
document_id: str,
|
| 14 |
+
source_file_name: str
|
| 15 |
+
) -> List[RichContentBlock]:
|
| 16 |
+
|
| 17 |
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 18 |
+
latex = f.read()
|
| 19 |
+
|
| 20 |
+
latex = remove_comments(latex)
|
| 21 |
+
|
| 22 |
+
blocks = []
|
| 23 |
+
block_counter = 1
|
| 24 |
+
|
| 25 |
+
formula_patterns = [
|
| 26 |
+
("equation", r"\\begin\{equation\*?\}(.*?)\\end\{equation\*?\}"),
|
| 27 |
+
("align", r"\\begin\{align\*?\}(.*?)\\end\{align\*?\}"),
|
| 28 |
+
("display_math", r"\\\[(.*?)\\\]")
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
for env_name, pattern in formula_patterns:
|
| 32 |
+
for match in re.finditer(pattern, latex, flags=re.DOTALL):
|
| 33 |
+
formula = " ".join(match.group(1).split()).strip()
|
| 34 |
+
if not formula:
|
| 35 |
+
continue
|
| 36 |
+
|
| 37 |
+
blocks.append(
|
| 38 |
+
RichContentBlock(
|
| 39 |
+
block_id=f"{document_id}_latex_block_{block_counter}",
|
| 40 |
+
document_id=document_id,
|
| 41 |
+
content_type="formula",
|
| 42 |
+
content=formula,
|
| 43 |
+
page_number=None,
|
| 44 |
+
section_title=None,
|
| 45 |
+
source_file_name=source_file_name,
|
| 46 |
+
metadata={
|
| 47 |
+
"parser": "LatexParser",
|
| 48 |
+
"original_format": "tex",
|
| 49 |
+
"latex_environment": env_name
|
| 50 |
+
}
|
| 51 |
+
)
|
| 52 |
+
)
|
| 53 |
+
block_counter += 1
|
| 54 |
+
|
| 55 |
+
latex = latex.replace(match.group(0), " ")
|
| 56 |
+
|
| 57 |
+
text = clean_latex_text(latex)
|
| 58 |
+
|
| 59 |
+
if text:
|
| 60 |
+
blocks.append(
|
| 61 |
+
RichContentBlock(
|
| 62 |
+
block_id=f"{document_id}_latex_block_{block_counter}",
|
| 63 |
+
document_id=document_id,
|
| 64 |
+
content_type="text",
|
| 65 |
+
content=text,
|
| 66 |
+
page_number=None,
|
| 67 |
+
section_title=None,
|
| 68 |
+
source_file_name=source_file_name,
|
| 69 |
+
metadata={
|
| 70 |
+
"parser": "LatexParser",
|
| 71 |
+
"original_format": "tex"
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
return blocks
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def remove_comments(text: str) -> str:
|
| 80 |
+
lines = []
|
| 81 |
+
for line in text.splitlines():
|
| 82 |
+
lines.append(re.sub(r"(?<!\\)%.*", "", line))
|
| 83 |
+
return "\n".join(lines)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def clean_latex_text(text: str) -> str:
|
| 87 |
+
text = re.sub(r"\\documentclass(\[.*?\])?\{.*?\}", " ", text)
|
| 88 |
+
text = re.sub(r"\\usepackage(\[.*?\])?\{.*?\}", " ", text)
|
| 89 |
+
text = re.sub(r"\\begin\{document\}", " ", text)
|
| 90 |
+
text = re.sub(r"\\end\{document\}", " ", text)
|
| 91 |
+
text = re.sub(r"\\(section|subsection|subsubsection)\*?\{(.*?)\}", r"\2\n\n", text)
|
| 92 |
+
text = re.sub(r"\\textbf\{(.*?)\}", r"\1", text)
|
| 93 |
+
text = re.sub(r"\\textit\{(.*?)\}", r"\1", text)
|
| 94 |
+
text = re.sub(r"\\cite\{.*?\}", "[citation]", text)
|
| 95 |
+
text = re.sub(r"\\label\{.*?\}", " ", text)
|
| 96 |
+
text = re.sub(r"\\[a-zA-Z]+\*?(\[.*?\])?(\{.*?\})?", " ", text)
|
| 97 |
+
text = text.replace("{", "").replace("}", "")
|
| 98 |
+
text = re.sub(r"\s+", " ", text)
|
| 99 |
+
return text.strip()
|
app/ingestion/markdown_parser.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from app.ingestion.base_parser import BaseParser
|
| 3 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class MarkdownParser(BaseParser):
|
| 7 |
+
|
| 8 |
+
def parse(
|
| 9 |
+
self,
|
| 10 |
+
file_path: str,
|
| 11 |
+
document_id: str,
|
| 12 |
+
source_file_name: str
|
| 13 |
+
) -> List[RichContentBlock]:
|
| 14 |
+
|
| 15 |
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 16 |
+
text = f.read()
|
| 17 |
+
|
| 18 |
+
if not text.strip():
|
| 19 |
+
return []
|
| 20 |
+
|
| 21 |
+
return [
|
| 22 |
+
RichContentBlock(
|
| 23 |
+
block_id=f"{document_id}_markdown_block_1",
|
| 24 |
+
document_id=document_id,
|
| 25 |
+
content_type="text",
|
| 26 |
+
content=text,
|
| 27 |
+
page_number=None,
|
| 28 |
+
section_title=None,
|
| 29 |
+
source_file_name=source_file_name,
|
| 30 |
+
metadata={
|
| 31 |
+
"parser": "MarkdownParser",
|
| 32 |
+
"original_format": "markdown"
|
| 33 |
+
}
|
| 34 |
+
)
|
| 35 |
+
]
|
app/ingestion/parser_registry.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from app.ingestion.base_parser import BaseParser
|
| 4 |
+
from app.ingestion.txt_parser import TxtParser
|
| 5 |
+
from app.ingestion.markdown_parser import MarkdownParser
|
| 6 |
+
from app.ingestion.pdf_parser import PdfParser
|
| 7 |
+
from app.ingestion.docx_parser import DocxParser
|
| 8 |
+
from app.ingestion.csv_excel_parser import CsvExcelParser
|
| 9 |
+
from app.ingestion.html_parser import HtmlParser
|
| 10 |
+
from app.ingestion.latex_parser import LatexParser
|
| 11 |
+
from app.ingestion.image_parser import ImageParser
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ParserRegistry:
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.parsers = {}
|
| 18 |
+
|
| 19 |
+
def register(self, file_type: str, parser: BaseParser):
|
| 20 |
+
self.parsers[file_type] = parser
|
| 21 |
+
|
| 22 |
+
def get_parser(self, file_type: str) -> Optional[BaseParser]:
|
| 23 |
+
return self.parsers.get(file_type)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
parser_registry = ParserRegistry()
|
| 27 |
+
|
| 28 |
+
parser_registry.register("txt", TxtParser())
|
| 29 |
+
parser_registry.register("markdown", MarkdownParser())
|
| 30 |
+
parser_registry.register("pdf", PdfParser())
|
| 31 |
+
parser_registry.register("docx", DocxParser())
|
| 32 |
+
|
| 33 |
+
table_parser = CsvExcelParser()
|
| 34 |
+
parser_registry.register("csv", table_parser)
|
| 35 |
+
parser_registry.register("excel", table_parser)
|
| 36 |
+
|
| 37 |
+
parser_registry.register("html", HtmlParser())
|
| 38 |
+
parser_registry.register("latex", LatexParser())
|
| 39 |
+
parser_registry.register("image", ImageParser())
|
app/ingestion/pdf_parser.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
import pymupdf
|
| 3 |
+
|
| 4 |
+
from app.ingestion.base_parser import BaseParser
|
| 5 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class PdfParser(BaseParser):
|
| 9 |
+
|
| 10 |
+
def parse(
|
| 11 |
+
self,
|
| 12 |
+
file_path: str,
|
| 13 |
+
document_id: str,
|
| 14 |
+
source_file_name: str
|
| 15 |
+
) -> List[RichContentBlock]:
|
| 16 |
+
|
| 17 |
+
blocks = []
|
| 18 |
+
pdf_document = pymupdf.open(file_path)
|
| 19 |
+
|
| 20 |
+
for page_index in range(len(pdf_document)):
|
| 21 |
+
page = pdf_document[page_index]
|
| 22 |
+
text = page.get_text("text").strip()
|
| 23 |
+
|
| 24 |
+
if not text:
|
| 25 |
+
continue
|
| 26 |
+
|
| 27 |
+
blocks.append(
|
| 28 |
+
RichContentBlock(
|
| 29 |
+
block_id=f"{document_id}_page_{page_index + 1}",
|
| 30 |
+
document_id=document_id,
|
| 31 |
+
content_type="text",
|
| 32 |
+
content=text,
|
| 33 |
+
page_number=page_index + 1,
|
| 34 |
+
section_title=None,
|
| 35 |
+
source_file_name=source_file_name,
|
| 36 |
+
metadata={
|
| 37 |
+
"parser": "PdfParser",
|
| 38 |
+
"original_format": "pdf",
|
| 39 |
+
"page_index_zero_based": page_index
|
| 40 |
+
}
|
| 41 |
+
)
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
pdf_document.close()
|
| 45 |
+
return blocks
|
app/ingestion/reprocessing_service.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Dict, Any, Optional
|
| 4 |
+
|
| 5 |
+
from app.ingestion.file_detector import detect_file_type
|
| 6 |
+
from app.ingestion.parser_registry import parser_registry
|
| 7 |
+
from app.chunking.chunking_service import chunk_blocks
|
| 8 |
+
from app.storage.processed_storage import save_processed_document
|
| 9 |
+
from app.storage.status_storage import (
|
| 10 |
+
read_document_status,
|
| 11 |
+
update_document_status
|
| 12 |
+
)
|
| 13 |
+
from app.storage.document_index import register_document_hash
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def calculate_file_hash(file_path: str) -> str:
|
| 17 |
+
hasher = hashlib.sha256()
|
| 18 |
+
|
| 19 |
+
with open(file_path, "rb") as f:
|
| 20 |
+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
| 21 |
+
hasher.update(chunk)
|
| 22 |
+
|
| 23 |
+
return hasher.hexdigest()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def reprocess_document_by_id(document_id: str) -> Optional[Dict[str, Any]]:
|
| 27 |
+
existing_status = read_document_status(document_id)
|
| 28 |
+
|
| 29 |
+
if existing_status is None:
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
raw_upload_path = existing_status.metadata.get("uploaded_file_path")
|
| 33 |
+
|
| 34 |
+
if not raw_upload_path:
|
| 35 |
+
raise FileNotFoundError("Original uploaded file path is missing.")
|
| 36 |
+
|
| 37 |
+
raw_file = Path(raw_upload_path)
|
| 38 |
+
|
| 39 |
+
if not raw_file.exists():
|
| 40 |
+
raise FileNotFoundError(f"Original uploaded file does not exist: {raw_upload_path}")
|
| 41 |
+
|
| 42 |
+
source_file_name = existing_status.source_file_name
|
| 43 |
+
file_type = detect_file_type(source_file_name)
|
| 44 |
+
file_hash = calculate_file_hash(str(raw_file))
|
| 45 |
+
|
| 46 |
+
parser = parser_registry.get_parser(file_type)
|
| 47 |
+
|
| 48 |
+
if parser is None:
|
| 49 |
+
return {
|
| 50 |
+
"status": "failed",
|
| 51 |
+
"message": "Parser for this file type is not implemented yet.",
|
| 52 |
+
"document_id": document_id,
|
| 53 |
+
"file_type": file_type
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
update_document_status(
|
| 57 |
+
document_id=document_id,
|
| 58 |
+
status="processing",
|
| 59 |
+
current_stage="reprocessing",
|
| 60 |
+
file_type=file_type,
|
| 61 |
+
message="Re-processing document."
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
blocks = parser.parse(
|
| 65 |
+
file_path=str(raw_file),
|
| 66 |
+
document_id=document_id,
|
| 67 |
+
source_file_name=source_file_name
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
chunks = chunk_blocks(blocks)
|
| 71 |
+
|
| 72 |
+
processed_metadata = save_processed_document(
|
| 73 |
+
document_id=document_id,
|
| 74 |
+
source_file_name=source_file_name,
|
| 75 |
+
file_type=file_type,
|
| 76 |
+
file_hash=file_hash,
|
| 77 |
+
blocks=blocks,
|
| 78 |
+
chunks=chunks
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
register_document_hash(
|
| 82 |
+
document_id=document_id,
|
| 83 |
+
source_file_name=source_file_name,
|
| 84 |
+
file_type=file_type,
|
| 85 |
+
file_hash=file_hash
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
update_document_status(
|
| 89 |
+
document_id=document_id,
|
| 90 |
+
status="processed",
|
| 91 |
+
current_stage="processed",
|
| 92 |
+
file_type=file_type,
|
| 93 |
+
message="Document re-processed successfully.",
|
| 94 |
+
metadata={
|
| 95 |
+
"last_operation": "reprocess",
|
| 96 |
+
"processed_files": processed_metadata["processed_files"]
|
| 97 |
+
}
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"status": "success",
|
| 102 |
+
"message": "Document re-processed successfully.",
|
| 103 |
+
"document_id": document_id,
|
| 104 |
+
"file_type": file_type,
|
| 105 |
+
"blocks_created": len(blocks),
|
| 106 |
+
"chunks_created": len(chunks)
|
| 107 |
+
}
|
app/ingestion/txt_parser.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List
|
| 2 |
+
from app.ingestion.base_parser import BaseParser
|
| 3 |
+
from app.schemas.rich_content_block import RichContentBlock
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TxtParser(BaseParser):
|
| 7 |
+
|
| 8 |
+
def parse(
|
| 9 |
+
self,
|
| 10 |
+
file_path: str,
|
| 11 |
+
document_id: str,
|
| 12 |
+
source_file_name: str
|
| 13 |
+
) -> List[RichContentBlock]:
|
| 14 |
+
|
| 15 |
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
| 16 |
+
text = f.read()
|
| 17 |
+
|
| 18 |
+
if not text.strip():
|
| 19 |
+
return []
|
| 20 |
+
|
| 21 |
+
return [
|
| 22 |
+
RichContentBlock(
|
| 23 |
+
block_id=f"{document_id}_txt_block_1",
|
| 24 |
+
document_id=document_id,
|
| 25 |
+
content_type="text",
|
| 26 |
+
content=text,
|
| 27 |
+
page_number=None,
|
| 28 |
+
section_title=None,
|
| 29 |
+
source_file_name=source_file_name,
|
| 30 |
+
metadata={
|
| 31 |
+
"parser": "TxtParser",
|
| 32 |
+
"original_format": "txt"
|
| 33 |
+
}
|
| 34 |
+
)
|
| 35 |
+
]
|
app/main.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, Query
|
| 3 |
+
from fastapi.staticfiles import StaticFiles
|
| 4 |
+
from fastapi.responses import HTMLResponse
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.schemas.query_schema import AskRequest
|
| 8 |
+
from app.schemas.evaluation_schema import (
|
| 9 |
+
RetrievalTestCaseCreate,
|
| 10 |
+
RetrievalEvaluationRunRequest,
|
| 11 |
+
AnswerTestCaseCreate,
|
| 12 |
+
AnswerEvaluationRunRequest
|
| 13 |
+
)
|
| 14 |
+
from app.ingestion.ingestion_service import process_uploaded_file
|
| 15 |
+
from app.ingestion.reprocessing_service import reprocess_document_by_id
|
| 16 |
+
from app.storage.status_storage import (
|
| 17 |
+
read_document_status,
|
| 18 |
+
list_document_statuses
|
| 19 |
+
)
|
| 20 |
+
from app.storage.processed_storage import (
|
| 21 |
+
read_processed_chunks,
|
| 22 |
+
read_processed_metadata
|
| 23 |
+
)
|
| 24 |
+
from app.storage.document_delete_service import delete_document_by_id
|
| 25 |
+
from app.retrieval.indexing_service import index_document_chunks
|
| 26 |
+
from app.retrieval.hybrid_search_service import retrieve_chunks
|
| 27 |
+
from app.generation.answer_service import answer_question
|
| 28 |
+
from app.generation.llm_service import get_llm_status, get_loaded_llm_info
|
| 29 |
+
from app.deployment.hf_status import (
|
| 30 |
+
get_deployment_health,
|
| 31 |
+
get_deployment_config,
|
| 32 |
+
get_demo_html
|
| 33 |
+
)
|
| 34 |
+
from app.evaluation.retrieval_eval_storage import (
|
| 35 |
+
load_retrieval_test_cases,
|
| 36 |
+
add_retrieval_test_case,
|
| 37 |
+
delete_retrieval_test_case
|
| 38 |
+
)
|
| 39 |
+
from app.evaluation.retrieval_evaluator import run_retrieval_evaluation
|
| 40 |
+
from app.evaluation.answer_eval_storage import (
|
| 41 |
+
load_answer_test_cases,
|
| 42 |
+
add_answer_test_case,
|
| 43 |
+
delete_answer_test_case
|
| 44 |
+
)
|
| 45 |
+
from app.evaluation.answer_evaluator import run_answer_evaluation
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
app = FastAPI(
|
| 49 |
+
title=settings.APP_NAME,
|
| 50 |
+
description="A production-grade multimodal GraphRAG research assistant",
|
| 51 |
+
version=settings.APP_VERSION
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
if settings.ENABLE_STATIC_ASSETS:
|
| 56 |
+
app.mount(
|
| 57 |
+
"/processed-assets",
|
| 58 |
+
StaticFiles(directory=str(settings.PROCESSED_DIR)),
|
| 59 |
+
name="processed-assets"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.get("/")
|
| 64 |
+
def health_check():
|
| 65 |
+
return {
|
| 66 |
+
"status": "running",
|
| 67 |
+
"message": f"{settings.APP_NAME} backend is alive",
|
| 68 |
+
"environment": settings.ENVIRONMENT,
|
| 69 |
+
"version": settings.APP_VERSION,
|
| 70 |
+
"phase": "Phase 11 - Hugging Face Deployment Readiness"
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@app.get("/llm/status")
|
| 75 |
+
def llm_status():
|
| 76 |
+
return get_llm_status()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@app.get("/llm/load-test")
|
| 80 |
+
def llm_load_test():
|
| 81 |
+
return get_loaded_llm_info()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@app.post("/upload")
|
| 85 |
+
async def upload_document(file: UploadFile = File(...)):
|
| 86 |
+
return await process_uploaded_file(file)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.get("/documents")
|
| 90 |
+
def list_documents():
|
| 91 |
+
documents = list_document_statuses()
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
"total_documents": len(documents),
|
| 95 |
+
"documents": documents
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@app.get("/documents/{document_id}/status")
|
| 100 |
+
def get_document_status(document_id: str):
|
| 101 |
+
status = read_document_status(document_id)
|
| 102 |
+
|
| 103 |
+
if status is None:
|
| 104 |
+
raise HTTPException(
|
| 105 |
+
status_code=404,
|
| 106 |
+
detail="Document status not found."
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
return status
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@app.get("/documents/{document_id}/chunks")
|
| 113 |
+
def get_document_chunks(
|
| 114 |
+
document_id: str,
|
| 115 |
+
limit: int = Query(20, ge=1, le=100),
|
| 116 |
+
offset: int = Query(0, ge=0),
|
| 117 |
+
content_type: Optional[str] = None
|
| 118 |
+
):
|
| 119 |
+
chunks = read_processed_chunks(document_id)
|
| 120 |
+
metadata = read_processed_metadata(document_id)
|
| 121 |
+
|
| 122 |
+
if chunks is None:
|
| 123 |
+
raise HTTPException(
|
| 124 |
+
status_code=404,
|
| 125 |
+
detail="Chunks not found for this document."
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
if content_type is not None:
|
| 129 |
+
chunks = [
|
| 130 |
+
chunk for chunk in chunks
|
| 131 |
+
if chunk.content_type == content_type
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
total_chunks = len(chunks)
|
| 135 |
+
paginated_chunks = chunks[offset: offset + limit]
|
| 136 |
+
|
| 137 |
+
return {
|
| 138 |
+
"document_id": document_id,
|
| 139 |
+
"metadata": metadata,
|
| 140 |
+
"total_chunks": total_chunks,
|
| 141 |
+
"returned_chunks": len(paginated_chunks),
|
| 142 |
+
"offset": offset,
|
| 143 |
+
"limit": limit,
|
| 144 |
+
"content_type_filter": content_type,
|
| 145 |
+
"chunks": paginated_chunks
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@app.post("/documents/{document_id}/index")
|
| 150 |
+
def index_document(document_id: str):
|
| 151 |
+
result = index_document_chunks(document_id)
|
| 152 |
+
|
| 153 |
+
if result["status"] == "failed":
|
| 154 |
+
raise HTTPException(
|
| 155 |
+
status_code=400,
|
| 156 |
+
detail=result["message"]
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return result
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@app.get("/search")
|
| 163 |
+
def search_documents(
|
| 164 |
+
query: str = Query(..., min_length=1),
|
| 165 |
+
document_id: Optional[str] = None,
|
| 166 |
+
top_k: int = Query(5, ge=1, le=20),
|
| 167 |
+
retrieval_mode: str = Query("hybrid")
|
| 168 |
+
):
|
| 169 |
+
return retrieve_chunks(
|
| 170 |
+
query=query,
|
| 171 |
+
document_id=document_id,
|
| 172 |
+
top_k=top_k,
|
| 173 |
+
retrieval_mode=retrieval_mode
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@app.post("/ask")
|
| 178 |
+
def ask_question(request: AskRequest):
|
| 179 |
+
return answer_question(
|
| 180 |
+
query=request.query,
|
| 181 |
+
document_id=request.document_id,
|
| 182 |
+
top_k=request.top_k,
|
| 183 |
+
retrieval_mode=request.retrieval_mode,
|
| 184 |
+
use_reranker=request.use_reranker,
|
| 185 |
+
use_llm=request.use_llm
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@app.get("/evaluation/retrieval/test-cases")
|
| 190 |
+
def list_retrieval_test_cases():
|
| 191 |
+
test_cases = load_retrieval_test_cases()
|
| 192 |
+
|
| 193 |
+
return {
|
| 194 |
+
"total_test_cases": len(test_cases),
|
| 195 |
+
"test_cases": test_cases
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@app.post("/evaluation/retrieval/test-cases")
|
| 200 |
+
def create_retrieval_test_case(test_case: RetrievalTestCaseCreate):
|
| 201 |
+
created_test_case = add_retrieval_test_case(test_case)
|
| 202 |
+
|
| 203 |
+
return {
|
| 204 |
+
"status": "success",
|
| 205 |
+
"message": "Retrieval test case created.",
|
| 206 |
+
"test_case": created_test_case
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@app.delete("/evaluation/retrieval/test-cases/{test_case_id}")
|
| 211 |
+
def remove_retrieval_test_case(test_case_id: str):
|
| 212 |
+
deleted = delete_retrieval_test_case(test_case_id)
|
| 213 |
+
|
| 214 |
+
if not deleted:
|
| 215 |
+
raise HTTPException(
|
| 216 |
+
status_code=404,
|
| 217 |
+
detail="Retrieval test case not found."
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
return {
|
| 221 |
+
"status": "success",
|
| 222 |
+
"message": "Retrieval test case deleted.",
|
| 223 |
+
"test_case_id": test_case_id
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@app.post("/evaluation/retrieval/run")
|
| 228 |
+
def run_retrieval_eval(request: RetrievalEvaluationRunRequest):
|
| 229 |
+
return run_retrieval_evaluation(request)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@app.get("/evaluation/answer/test-cases")
|
| 233 |
+
def list_answer_test_cases():
|
| 234 |
+
test_cases = load_answer_test_cases()
|
| 235 |
+
|
| 236 |
+
return {
|
| 237 |
+
"total_test_cases": len(test_cases),
|
| 238 |
+
"test_cases": test_cases
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
@app.post("/evaluation/answer/test-cases")
|
| 243 |
+
def create_answer_test_case(test_case: AnswerTestCaseCreate):
|
| 244 |
+
created_test_case = add_answer_test_case(test_case)
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"status": "success",
|
| 248 |
+
"message": "Answer test case created.",
|
| 249 |
+
"test_case": created_test_case
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@app.delete("/evaluation/answer/test-cases/{test_case_id}")
|
| 254 |
+
def remove_answer_test_case(test_case_id: str):
|
| 255 |
+
deleted = delete_answer_test_case(test_case_id)
|
| 256 |
+
|
| 257 |
+
if not deleted:
|
| 258 |
+
raise HTTPException(
|
| 259 |
+
status_code=404,
|
| 260 |
+
detail="Answer test case not found."
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
return {
|
| 264 |
+
"status": "success",
|
| 265 |
+
"message": "Answer test case deleted.",
|
| 266 |
+
"test_case_id": test_case_id
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
@app.post("/evaluation/answer/run")
|
| 271 |
+
def run_answer_eval(request: AnswerEvaluationRunRequest):
|
| 272 |
+
return run_answer_evaluation(request)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@app.post("/documents/{document_id}/reprocess")
|
| 276 |
+
def reprocess_document(document_id: str):
|
| 277 |
+
try:
|
| 278 |
+
result = reprocess_document_by_id(document_id)
|
| 279 |
+
|
| 280 |
+
except FileNotFoundError as error:
|
| 281 |
+
raise HTTPException(
|
| 282 |
+
status_code=404,
|
| 283 |
+
detail=str(error)
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
except Exception as error:
|
| 287 |
+
raise HTTPException(
|
| 288 |
+
status_code=500,
|
| 289 |
+
detail=f"Document re-processing failed: {str(error)}"
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
if result is None:
|
| 293 |
+
raise HTTPException(
|
| 294 |
+
status_code=404,
|
| 295 |
+
detail="Document not found."
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
return result
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
@app.delete("/documents/{document_id}")
|
| 302 |
+
def delete_document(document_id: str):
|
| 303 |
+
deletion_result = delete_document_by_id(document_id)
|
| 304 |
+
|
| 305 |
+
if deletion_result is None:
|
| 306 |
+
raise HTTPException(
|
| 307 |
+
status_code=404,
|
| 308 |
+
detail="Document not found."
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
return {
|
| 312 |
+
"status": "success",
|
| 313 |
+
"message": "Document deleted successfully.",
|
| 314 |
+
"deletion_result": deletion_result
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# Hugging Face deployment endpoints
|
| 319 |
+
|
| 320 |
+
@app.get("/deployment/health")
|
| 321 |
+
def deployment_health():
|
| 322 |
+
return get_deployment_health()
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
@app.get("/deployment/config")
|
| 326 |
+
def deployment_config():
|
| 327 |
+
return get_deployment_config()
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
@app.get("/demo", response_class=HTMLResponse)
|
| 331 |
+
def demo_page():
|
| 332 |
+
return get_demo_html()
|
app/retrieval/__init__.py
ADDED
|
File without changes
|
app/retrieval/citation_service.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Any
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def build_citation_text(payload: Dict[str, Any], source_id: str) -> str:
|
| 5 |
+
source_file_name = payload.get("source_file_name", "Unknown file")
|
| 6 |
+
page_number = payload.get("page_number")
|
| 7 |
+
section_title = payload.get("section_title")
|
| 8 |
+
|
| 9 |
+
parts = [source_id, source_file_name]
|
| 10 |
+
|
| 11 |
+
if page_number:
|
| 12 |
+
parts.append(f"page {page_number}")
|
| 13 |
+
|
| 14 |
+
if section_title:
|
| 15 |
+
parts.append(f"section: {section_title}")
|
| 16 |
+
|
| 17 |
+
return "[" + " | ".join(parts) + "]"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def attach_source_ids(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 21 |
+
updated_results = []
|
| 22 |
+
|
| 23 |
+
for index, result in enumerate(results, start=1):
|
| 24 |
+
source_id = f"S{index}"
|
| 25 |
+
|
| 26 |
+
payload = {
|
| 27 |
+
"source_file_name": result.get("source_file_name"),
|
| 28 |
+
"page_number": result.get("page_number"),
|
| 29 |
+
"section_title": result.get("section_title")
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
result = dict(result)
|
| 33 |
+
result["source_id"] = source_id
|
| 34 |
+
result["citation"] = build_citation_text(payload, source_id)
|
| 35 |
+
|
| 36 |
+
updated_results.append(result)
|
| 37 |
+
|
| 38 |
+
return updated_results
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def create_context_from_sources(
|
| 42 |
+
results: List[Dict[str, Any]],
|
| 43 |
+
max_context_chars: int
|
| 44 |
+
) -> str:
|
| 45 |
+
context_parts = []
|
| 46 |
+
used_chars = 0
|
| 47 |
+
|
| 48 |
+
for result in results:
|
| 49 |
+
source_id = result.get("source_id", "S?")
|
| 50 |
+
citation = result.get("citation", "")
|
| 51 |
+
content = result.get("content", "")
|
| 52 |
+
|
| 53 |
+
context_piece = (
|
| 54 |
+
f"{source_id}\n"
|
| 55 |
+
f"Citation: {citation}\n"
|
| 56 |
+
f"Content:\n{content}\n"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if used_chars + len(context_piece) > max_context_chars:
|
| 60 |
+
remaining_chars = max_context_chars - used_chars
|
| 61 |
+
|
| 62 |
+
if remaining_chars <= 0:
|
| 63 |
+
break
|
| 64 |
+
|
| 65 |
+
context_piece = context_piece[:remaining_chars]
|
| 66 |
+
|
| 67 |
+
context_parts.append(context_piece)
|
| 68 |
+
used_chars += len(context_piece)
|
| 69 |
+
|
| 70 |
+
return "\n---\n".join(context_parts)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def create_citation_objects(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 74 |
+
citations = []
|
| 75 |
+
|
| 76 |
+
for result in results:
|
| 77 |
+
content = result.get("content", "")
|
| 78 |
+
|
| 79 |
+
citations.append(
|
| 80 |
+
{
|
| 81 |
+
"source_id": result.get("source_id"),
|
| 82 |
+
"chunk_id": result.get("chunk_id"),
|
| 83 |
+
"document_id": result.get("document_id"),
|
| 84 |
+
"source_file_name": result.get("source_file_name"),
|
| 85 |
+
"page_number": result.get("page_number"),
|
| 86 |
+
"section_title": result.get("section_title"),
|
| 87 |
+
"content_type": result.get("content_type"),
|
| 88 |
+
"score": result.get("score"),
|
| 89 |
+
"citation_text": result.get("citation"),
|
| 90 |
+
"content_preview": content[:500],
|
| 91 |
+
"metadata": result.get("metadata", {})
|
| 92 |
+
}
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
return citations
|
app/retrieval/embedding_service.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from typing import List
|
| 3 |
+
import numpy as np
|
| 4 |
+
from sentence_transformers import SentenceTransformer
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@lru_cache(maxsize=1)
|
| 10 |
+
def get_embedding_model() -> SentenceTransformer:
|
| 11 |
+
return SentenceTransformer(settings.EMBEDDING_MODEL_NAME, device="cpu")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def embed_texts(texts: List[str]) -> List[List[float]]:
|
| 15 |
+
if not texts:
|
| 16 |
+
return []
|
| 17 |
+
|
| 18 |
+
model = get_embedding_model()
|
| 19 |
+
|
| 20 |
+
embeddings = model.encode(
|
| 21 |
+
texts,
|
| 22 |
+
normalize_embeddings=True,
|
| 23 |
+
show_progress_bar=False
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
if isinstance(embeddings, np.ndarray):
|
| 27 |
+
return embeddings.tolist()
|
| 28 |
+
|
| 29 |
+
return embeddings
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def embed_text(text: str) -> List[float]:
|
| 33 |
+
return embed_texts([text])[0]
|
app/retrieval/hybrid_search_service.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional, Dict, Any, List
|
| 2 |
+
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
from app.retrieval.search_service import search_relevant_chunks
|
| 5 |
+
from app.retrieval.keyword_search_service import keyword_search_chunks
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def min_max_normalize(score_map: Dict[str, float]) -> Dict[str, float]:
|
| 9 |
+
if not score_map:
|
| 10 |
+
return {}
|
| 11 |
+
|
| 12 |
+
values = list(score_map.values())
|
| 13 |
+
min_value = min(values)
|
| 14 |
+
max_value = max(values)
|
| 15 |
+
|
| 16 |
+
if max_value == min_value:
|
| 17 |
+
return {key: 1.0 for key in score_map}
|
| 18 |
+
|
| 19 |
+
return {
|
| 20 |
+
key: (value - min_value) / (max_value - min_value)
|
| 21 |
+
for key, value in score_map.items()
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def hybrid_search_chunks(
|
| 26 |
+
query: str,
|
| 27 |
+
document_id: Optional[str] = None,
|
| 28 |
+
top_k: int = 5,
|
| 29 |
+
candidate_k: Optional[int] = None
|
| 30 |
+
) -> Dict[str, Any]:
|
| 31 |
+
|
| 32 |
+
if candidate_k is None:
|
| 33 |
+
candidate_k = max(top_k * 4, 20)
|
| 34 |
+
|
| 35 |
+
vector_output = search_relevant_chunks(
|
| 36 |
+
query=query,
|
| 37 |
+
document_id=document_id,
|
| 38 |
+
top_k=candidate_k
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
keyword_output = keyword_search_chunks(
|
| 42 |
+
query=query,
|
| 43 |
+
document_id=document_id,
|
| 44 |
+
top_k=candidate_k
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
combined = {}
|
| 48 |
+
|
| 49 |
+
vector_scores = {}
|
| 50 |
+
keyword_scores = {}
|
| 51 |
+
|
| 52 |
+
for result in vector_output["results"]:
|
| 53 |
+
chunk_id = result["chunk_id"]
|
| 54 |
+
combined[chunk_id] = result
|
| 55 |
+
vector_scores[chunk_id] = float(result.get("vector_score") or result.get("score") or 0.0)
|
| 56 |
+
|
| 57 |
+
for result in keyword_output["results"]:
|
| 58 |
+
chunk_id = result["chunk_id"]
|
| 59 |
+
|
| 60 |
+
if chunk_id not in combined:
|
| 61 |
+
combined[chunk_id] = result
|
| 62 |
+
|
| 63 |
+
keyword_scores[chunk_id] = float(result.get("keyword_score") or result.get("score") or 0.0)
|
| 64 |
+
|
| 65 |
+
normalized_vector_scores = min_max_normalize(vector_scores)
|
| 66 |
+
normalized_keyword_scores = min_max_normalize(keyword_scores)
|
| 67 |
+
|
| 68 |
+
ranked_results = []
|
| 69 |
+
|
| 70 |
+
for chunk_id, result in combined.items():
|
| 71 |
+
vector_score = normalized_vector_scores.get(chunk_id, 0.0)
|
| 72 |
+
keyword_score = normalized_keyword_scores.get(chunk_id, 0.0)
|
| 73 |
+
|
| 74 |
+
hybrid_score = (
|
| 75 |
+
settings.HYBRID_VECTOR_WEIGHT * vector_score
|
| 76 |
+
+ settings.HYBRID_KEYWORD_WEIGHT * keyword_score
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
result = dict(result)
|
| 80 |
+
result["vector_score"] = vector_scores.get(chunk_id)
|
| 81 |
+
result["keyword_score"] = keyword_scores.get(chunk_id)
|
| 82 |
+
result["hybrid_score"] = hybrid_score
|
| 83 |
+
result["score"] = hybrid_score
|
| 84 |
+
|
| 85 |
+
ranked_results.append(result)
|
| 86 |
+
|
| 87 |
+
ranked_results.sort(
|
| 88 |
+
key=lambda item: item["hybrid_score"],
|
| 89 |
+
reverse=True
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
return {
|
| 93 |
+
"query": query,
|
| 94 |
+
"document_id_filter": document_id,
|
| 95 |
+
"top_k": top_k,
|
| 96 |
+
"candidate_k": candidate_k,
|
| 97 |
+
"retrieval_mode": "hybrid",
|
| 98 |
+
"weights": {
|
| 99 |
+
"vector": settings.HYBRID_VECTOR_WEIGHT,
|
| 100 |
+
"keyword": settings.HYBRID_KEYWORD_WEIGHT
|
| 101 |
+
},
|
| 102 |
+
"results": ranked_results[:top_k]
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def retrieve_chunks(
|
| 107 |
+
query: str,
|
| 108 |
+
document_id: Optional[str] = None,
|
| 109 |
+
top_k: int = 5,
|
| 110 |
+
retrieval_mode: str = "hybrid"
|
| 111 |
+
) -> Dict[str, Any]:
|
| 112 |
+
|
| 113 |
+
if retrieval_mode == "vector":
|
| 114 |
+
output = search_relevant_chunks(
|
| 115 |
+
query=query,
|
| 116 |
+
document_id=document_id,
|
| 117 |
+
top_k=top_k
|
| 118 |
+
)
|
| 119 |
+
output["retrieval_mode"] = "vector"
|
| 120 |
+
return output
|
| 121 |
+
|
| 122 |
+
if retrieval_mode == "keyword":
|
| 123 |
+
output = keyword_search_chunks(
|
| 124 |
+
query=query,
|
| 125 |
+
document_id=document_id,
|
| 126 |
+
top_k=top_k
|
| 127 |
+
)
|
| 128 |
+
output["retrieval_mode"] = "keyword"
|
| 129 |
+
return output
|
| 130 |
+
|
| 131 |
+
return hybrid_search_chunks(
|
| 132 |
+
query=query,
|
| 133 |
+
document_id=document_id,
|
| 134 |
+
top_k=top_k
|
| 135 |
+
)
|
app/retrieval/indexing_service.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict, Any
|
| 2 |
+
|
| 3 |
+
from app.storage.processed_storage import read_processed_chunks
|
| 4 |
+
from app.storage.status_storage import update_document_status
|
| 5 |
+
from app.retrieval.embedding_service import embed_texts
|
| 6 |
+
from app.retrieval.vector_store import upsert_chunk_vectors
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def index_document_chunks(document_id: str) -> Dict[str, Any]:
|
| 10 |
+
chunks = read_processed_chunks(document_id)
|
| 11 |
+
|
| 12 |
+
if chunks is None:
|
| 13 |
+
return {
|
| 14 |
+
"status": "failed",
|
| 15 |
+
"message": "Chunks not found. Process the document before indexing.",
|
| 16 |
+
"document_id": document_id
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
indexable_chunks = [
|
| 20 |
+
chunk for chunk in chunks
|
| 21 |
+
if chunk.content and chunk.content.strip()
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
if not indexable_chunks:
|
| 25 |
+
return {
|
| 26 |
+
"status": "failed",
|
| 27 |
+
"message": "No indexable chunks found.",
|
| 28 |
+
"document_id": document_id
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
update_document_status(
|
| 32 |
+
document_id=document_id,
|
| 33 |
+
status="processing",
|
| 34 |
+
current_stage="embedding",
|
| 35 |
+
message="Creating embeddings for document chunks.",
|
| 36 |
+
metadata={
|
| 37 |
+
"chunks_to_index": len(indexable_chunks)
|
| 38 |
+
}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
texts = [
|
| 42 |
+
build_embedding_text(chunk)
|
| 43 |
+
for chunk in indexable_chunks
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
vectors = embed_texts(texts)
|
| 47 |
+
|
| 48 |
+
points = []
|
| 49 |
+
|
| 50 |
+
for chunk, vector in zip(indexable_chunks, vectors):
|
| 51 |
+
payload = {
|
| 52 |
+
"chunk_id": chunk.chunk_id,
|
| 53 |
+
"document_id": chunk.document_id,
|
| 54 |
+
"parent_block_id": chunk.parent_block_id,
|
| 55 |
+
"content_type": chunk.content_type,
|
| 56 |
+
"content": chunk.content,
|
| 57 |
+
"page_number": chunk.page_number,
|
| 58 |
+
"section_title": chunk.section_title,
|
| 59 |
+
"source_file_name": chunk.source_file_name,
|
| 60 |
+
"metadata": chunk.metadata
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
points.append({
|
| 64 |
+
"chunk_id": chunk.chunk_id,
|
| 65 |
+
"vector": vector,
|
| 66 |
+
"payload": payload
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
indexed_count = upsert_chunk_vectors(points)
|
| 70 |
+
|
| 71 |
+
update_document_status(
|
| 72 |
+
document_id=document_id,
|
| 73 |
+
status="processed",
|
| 74 |
+
current_stage="indexed",
|
| 75 |
+
message="Document chunks indexed successfully.",
|
| 76 |
+
metadata={
|
| 77 |
+
"indexed": True,
|
| 78 |
+
"indexed_chunks": indexed_count
|
| 79 |
+
}
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
"status": "success",
|
| 84 |
+
"message": "Document indexed successfully.",
|
| 85 |
+
"document_id": document_id,
|
| 86 |
+
"indexed_chunks": indexed_count
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def build_embedding_text(chunk) -> str:
|
| 91 |
+
prefix = f"Content type: {chunk.content_type}\n"
|
| 92 |
+
|
| 93 |
+
if chunk.section_title:
|
| 94 |
+
prefix += f"Section: {chunk.section_title}\n"
|
| 95 |
+
|
| 96 |
+
if chunk.page_number:
|
| 97 |
+
prefix += f"Page: {chunk.page_number}\n"
|
| 98 |
+
|
| 99 |
+
return prefix + chunk.content
|