yugbirla commited on
Commit
d4a141c
·
0 Parent(s):

Deploy GraphRAG FastAPI app to Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +33 -0
  2. .gitignore +30 -0
  3. Dockerfile +36 -0
  4. README.md +35 -0
  5. app/__init__.py +0 -0
  6. app/chunking/__init__.py +0 -0
  7. app/chunking/chunking_service.py +100 -0
  8. app/core/__init__.py +0 -0
  9. app/core/config.py +138 -0
  10. app/deployment/__init__.py +0 -0
  11. app/deployment/hf_status.py +54 -0
  12. app/evaluation/__init__.py +0 -0
  13. app/evaluation/answer_eval_storage.py +92 -0
  14. app/evaluation/answer_evaluator.py +367 -0
  15. app/evaluation/retrieval_eval_storage.py +87 -0
  16. app/evaluation/retrieval_evaluator.py +345 -0
  17. app/generation/__init__.py +0 -0
  18. app/generation/answer_quality_checker.py +110 -0
  19. app/generation/answer_service.py +475 -0
  20. app/generation/context_cleaner.py +72 -0
  21. app/generation/evidence_extractor.py +206 -0
  22. app/generation/llm_service.py +52 -0
  23. app/generation/prompt_builder.py +37 -0
  24. app/generation/provider_factory.py +29 -0
  25. app/generation/providers/__init__.py +0 -0
  26. app/generation/providers/base_provider.py +27 -0
  27. app/generation/providers/disabled_provider.py +24 -0
  28. app/generation/providers/huggingface_provider.py +133 -0
  29. app/generation/providers/local_provider.py +185 -0
  30. app/generation/question_classifier.py +69 -0
  31. app/ingestion/__init__.py +0 -0
  32. app/ingestion/base_parser.py +15 -0
  33. app/ingestion/csv_excel_parser.py +107 -0
  34. app/ingestion/docx_parser.py +120 -0
  35. app/ingestion/file_detector.py +25 -0
  36. app/ingestion/html_parser.py +104 -0
  37. app/ingestion/image_parser.py +69 -0
  38. app/ingestion/ingestion_service.py +247 -0
  39. app/ingestion/latex_parser.py +99 -0
  40. app/ingestion/markdown_parser.py +35 -0
  41. app/ingestion/parser_registry.py +39 -0
  42. app/ingestion/pdf_parser.py +45 -0
  43. app/ingestion/reprocessing_service.py +107 -0
  44. app/ingestion/txt_parser.py +35 -0
  45. app/main.py +332 -0
  46. app/retrieval/__init__.py +0 -0
  47. app/retrieval/citation_service.py +95 -0
  48. app/retrieval/embedding_service.py +33 -0
  49. app/retrieval/hybrid_search_service.py +135 -0
  50. 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