Kashish commited on
Commit
345991e
·
1 Parent(s): 3ae6e65

Initial commit

Browse files
.gitattributes CHANGED
@@ -1,3 +1,4 @@
 
1
  *.7z filter=lfs diff=lfs merge=lfs -text
2
  *.arrow filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
@@ -24,13 +25,13 @@
24
  *.rar filter=lfs diff=lfs merge=lfs -text
25
  *.safetensors filter=lfs diff=lfs merge=lfs -text
26
  saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
  *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
  *.tgz filter=lfs diff=lfs merge=lfs -text
 
31
  *.wasm filter=lfs diff=lfs merge=lfs -text
32
  *.xz filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- kb/vector_db/*.faiss filter=lfs diff=lfs merge=lfs -text
 
1
+ # Common large ML / data files
2
  *.7z filter=lfs diff=lfs merge=lfs -text
3
  *.arrow filter=lfs diff=lfs merge=lfs -text
4
  *.bin filter=lfs diff=lfs merge=lfs -text
 
25
  *.rar filter=lfs diff=lfs merge=lfs -text
26
  *.safetensors filter=lfs diff=lfs merge=lfs -text
27
  saved_model/**/* filter=lfs diff=lfs merge=lfs -text
 
28
  *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
30
  *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.tflite filter=lfs diff=lfs merge=lfs -text
32
  *.wasm filter=lfs diff=lfs merge=lfs -text
33
  *.xz filter=lfs diff=lfs merge=lfs -text
34
  *.zip filter=lfs diff=lfs merge=lfs -text
35
  *.zst filter=lfs diff=lfs merge=lfs -text
36
  *tfevents* filter=lfs diff=lfs merge=lfs -text
37
+ kb/vector_db/*.faiss filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,11 +1,30 @@
1
- .env
2
- .env.local
 
 
 
 
 
 
 
 
 
3
 
 
 
 
4
  *.log
5
  nutrition_planner.log
 
 
 
6
 
7
- __pycache__/
8
- venv/
9
- *.pyc
10
 
11
- kb/raw_data/pdf/
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+
7
+ venv/
8
+ .venv/
9
+ .env/
10
+ pip-log.txt
11
+ pip-delete-this-directory.txt
12
 
13
+ .env
14
+ .env.*
15
+ *.env
16
  *.log
17
  nutrition_planner.log
18
+ .vscode/
19
+ .idea/
20
+ *.sublime-*
21
 
22
+ kb/raw_data/
23
+ kb/raw_data/pdf/
24
+ kb/vector_db/vector_index_metadata.json
25
 
26
+ *.bak
27
+ *.swp
28
+ *.tmp
29
+ .DS_Store
30
+ Thumbs.db
README.md CHANGED
@@ -1,14 +1,38 @@
1
- ---
2
- title: Pawspal Nutrition Api
3
- emoji: 📊
4
- colorFrom: indigo
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
- app_port: 7860
10
- base_path: /docs
11
- short_description: AI-Powered Pet Health nutrition & diet planner
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PawsPalConnect Pet Nutrition API
2
+
3
+ Generate personalized veterinary-grade nutrition plans for dogs and cats.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ pip install -r requirements.txt
10
+
11
+ # Run API
12
+ uvicorn app:app --reload
13
+ ```
14
+
15
+
16
+ ## API
17
+
18
+ **POST** `/nutrition/plan`
19
+
20
+ ```json
21
+ {
22
+ "pet_type": "dog",
23
+ "breed": "Golden Retriever",
24
+ "gender": "male",
25
+ "pregnant_lactating": "none",
26
+ "weight_kg": 14,
27
+ "activity_level": "low",
28
+ "life_stage": "adult",
29
+ "bcs": 4,
30
+ "neutered_spayed": "yes",
31
+ "allergies": "almonds",
32
+ "diseases": "distemper",
33
+ "diet_preference": "vegetarian",
34
+ "current_food_format": "wet"
35
+ }
36
+ ```
37
+
38
+ **Response**: Personalized nutrition targets + meal plans with ingredients and preparation instructions
app.py CHANGED
@@ -1,10 +1,114 @@
 
 
 
1
  from fastapi import FastAPI
 
 
2
  from router import router
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  app = FastAPI(
5
- title="PawsPal Pet Nutrition API",
6
- version="1.0.0",
7
- )
 
 
 
 
 
 
 
 
 
 
8
 
9
  app.include_router(router)
10
-
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ import logging
3
+
4
  from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+
7
  from router import router
8
+ from kb.document_processor import DocumentProcessor
9
+ from rag.embed import EmbeddingLayer
10
+ from rag.vectorstore import VectorStore
11
+ from rag.retriever import Retriever, KnowledgeRetriever
12
+ from llm.gemini_client import GeminiClient
13
+ from sentence_transformers import CrossEncoder
14
+ from config import settings
15
+
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
19
+ )
20
+ logger = logging.getLogger(__name__)
21
+
22
+ class AppState:
23
+ embedder: EmbeddingLayer = None
24
+ vectorstore: VectorStore = None
25
+ cross_encoder: CrossEncoder = None
26
+ retriever: KnowledgeRetriever = None
27
+ llm_client: GeminiClient = None
28
+
29
+ state = AppState()
30
+
31
+ @asynccontextmanager
32
+ async def lifespan(app: FastAPI):
33
+
34
+ logger.info("STARTING PAWSPALCONNECT NUTRITION API")
35
+
36
+ logger.info("Loading embedding model...")
37
+ try:
38
+ state.embedder = EmbeddingLayer()
39
+ state.embedder._load_model() # Load model at startup
40
+ logger.info(f"Embedding model loaded: {settings.EMBEDDING_MODEL}")
41
+ except Exception as e:
42
+ logger.error(f"Failed to load embedder: {e}")
43
+ raise
44
+
45
+ logger.info("Loading vector store...")
46
+ try:
47
+ state.vectorstore = VectorStore.create_vectorstore()
48
+ if not state.vectorstore.load():
49
+ logger.warning("No existing vector store found - needs rebuild")
50
+ pdf_dir = settings.RAW_DATA_DIR / "pdf"
51
+ processor = DocumentProcessor.create_processor()
52
+ documents = processor.process_all_pdfs(pdf_dir)
53
+ texts = [doc.page_content for doc in documents]
54
+ metadatas = [doc.metadata for doc in documents]
55
+ embeddings = state.embedder.embed_texts(texts)
56
+ state.vectorstore.add(embeddings, texts, metadatas)
57
+ state.vectorstore.persist()
58
+ logger.info(f"Vector store built: {len(documents)} chunks")
59
+ else:
60
+ logger.info(f"Vector store loaded: {len(state.vectorstore.records)} chunks")
61
+ except Exception as e:
62
+ logger.error(f"Failed to load vector store: {e}")
63
+ raise
64
+
65
+ logger.info("Loading reranking model...")
66
+ try:
67
+ state.cross_encoder = CrossEncoder(settings.RERANK_MODEL)
68
+ logger.info(f"Cross-encoder loaded: {settings.RERANK_MODEL}")
69
+ except Exception as e:
70
+ logger.error(f"Failed to load cross-encoder: {e}")
71
+ raise
72
+
73
+ logger.info("Initializing retriever...")
74
+ try:
75
+ retriever = Retriever(state.embedder, state.vectorstore, state.cross_encoder)
76
+ state.retriever = KnowledgeRetriever(retriever)
77
+ logger.info("Retriever ready")
78
+ except Exception as e:
79
+ logger.error(f"Failed to initialize retriever: {e}")
80
+ raise
81
+
82
+ logger.info("Initializing Gemini client...")
83
+ try:
84
+ state.llm_client = GeminiClient.create()
85
+ logger.info("Gemini ready")
86
+ except Exception as e:
87
+ logger.error(f"Failed to initialize Gemini: {e}")
88
+ raise
89
+
90
+ logger.info("API STARTUP COMPLETE")
91
+
92
+ yield
93
+
94
+ logger.info("Shutting down...")
95
 
96
  app = FastAPI(
97
+ title="PawsPalConnect Pet Nutrition API",
98
+ version="2.0.0",
99
+ description="Generate personalized veterinary-grade nutrition plans for dogs and cats.",
100
+ lifespan=lifespan,
101
+ )
102
+
103
+ app.add_middleware(
104
+ CORSMiddleware,
105
+ allow_origins=["*"],
106
+ allow_credentials=True,
107
+ allow_methods=["*"],
108
+ allow_headers=["*"],
109
+ )
110
 
111
  app.include_router(router)
112
+
113
+ # Make state accessible to routes
114
+ app.state.app_state = state
chunker.py DELETED
@@ -1,165 +0,0 @@
1
- """
2
- Semantic text chunking for documents.
3
- """
4
- import logging
5
- from typing import List, Tuple, Dict, Any
6
- import re
7
- from dataclasses import dataclass
8
- from config import settings
9
-
10
- logger = logging.getLogger(__name__)
11
-
12
- @dataclass
13
- class DocumentChunk:
14
- content: str
15
- metadata: Dict[str, Any]
16
-
17
-
18
- class SemanticChunker:
19
-
20
- #Splits by headings, merges small chunks and maintains overlap.
21
-
22
- @staticmethod
23
- def create_chunker() -> "SemanticChunker":
24
- return SemanticChunker()
25
-
26
- def __init__(self):
27
- self.chunk_size = settings.CHUNK_SIZE
28
- self.overlap_ratio = settings.CHUNK_OVERLAP_RATIO
29
- self.min_chunk_size = settings.MIN_CHUNK_SIZE
30
- self.drop_front_matter_pages = settings.DROP_FRONT_MATTER_PAGES
31
-
32
- def chunk_document(self, content: str, source_file: str) -> Tuple[List[DocumentChunk], Dict[str, Any]]:
33
-
34
- # Chunk document semantically. Returns chunks and stats.
35
- pages = content.split("--- Page")
36
- if len(pages) > self.drop_front_matter_pages:
37
- content = "--- Page".join(pages[self.drop_front_matter_pages:])
38
- else:
39
- logger.warning("Document has fewer pages than drop_front_matter_pages")
40
-
41
- sections = self._split_by_headings(content)
42
-
43
- merged_sections = self._merge_small_sections(sections)
44
-
45
- chunks = self._create_overlapping_chunks(merged_sections, source_file)
46
-
47
- stats = {
48
- "total_sections": len(sections),
49
- "merged_sections": len(merged_sections),
50
- "final_chunks": len(chunks),
51
- }
52
-
53
- return chunks, stats
54
-
55
- def _split_by_headings(self, content: str) -> List[Dict[str, Any]]:
56
-
57
- # Pattern for headings (# ## ### etc.)
58
- heading_pattern = r'(^#{1,6}\s+.*$)'
59
- parts = re.split(heading_pattern, content, flags=re.MULTILINE)
60
-
61
- sections = []
62
- current_heading = ""
63
- current_content = []
64
-
65
- for part in parts:
66
- if re.match(heading_pattern, part.strip()):
67
-
68
- if current_content:
69
- sections.append({
70
- "heading": current_heading,
71
- "content": "\n".join(current_content).strip(),
72
- })
73
-
74
- current_heading = part.strip()
75
- current_content = [part]
76
- else:
77
- current_content.append(part)
78
-
79
- if current_content:
80
- sections.append({
81
- "heading": current_heading,
82
- "content": "\n".join(current_content).strip(),
83
- })
84
-
85
- return sections
86
-
87
- def _merge_small_sections(self, sections: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
88
-
89
- merged = []
90
- current = None
91
-
92
- for section in sections:
93
- content_tokens = len(section["content"].split())
94
-
95
- if current is None:
96
- current = section.copy()
97
- elif len(current["content"].split()) + content_tokens < self.min_chunk_size:
98
- # Merge
99
- current["content"] += "\n\n" + section["content"]
100
- current["heading"] += " + " + section["heading"]
101
- else:
102
- merged.append(current)
103
- current = section.copy()
104
-
105
- if current:
106
- merged.append(current)
107
-
108
- return merged
109
-
110
- def _create_overlapping_chunks(self, sections: List[Dict[str, Any]], source_file: str) -> List[DocumentChunk]:
111
-
112
- chunks = []
113
- chunk_id = 0
114
- overlap_tokens = int(self.chunk_size * self.overlap_ratio)
115
-
116
- current_chunk_content = ""
117
- current_chunk_sections = []
118
- page_start = 1
119
-
120
- for section in sections:
121
- section_tokens = len(section["content"].split())
122
-
123
- if len(current_chunk_content.split()) + section_tokens > self.chunk_size:
124
- # Create chunk
125
- if current_chunk_content:
126
- chunk = DocumentChunk(
127
- content=current_chunk_content.strip(),
128
- metadata={
129
- "chunk_id": chunk_id,
130
- "source_file": source_file,
131
- "page_range": f"{page_start}-",
132
- "section_heading": current_chunk_sections[0]["heading"] if current_chunk_sections else "",
133
- }
134
- )
135
- chunks.append(chunk)
136
- chunk_id += 1
137
-
138
- # Start new chunk with overlap
139
- overlap_content = self._get_overlap_content(current_chunk_content, overlap_tokens)
140
- current_chunk_content = overlap_content + "\n\n" + section["content"]
141
- current_chunk_sections = [section]
142
- else:
143
- current_chunk_content += "\n\n" + section["content"]
144
- current_chunk_sections.append(section)
145
-
146
- if current_chunk_content:
147
- chunk = DocumentChunk(
148
- content=current_chunk_content.strip(),
149
- metadata={
150
- "chunk_id": chunk_id,
151
- "source_file": source_file,
152
- "page_range": f"{page_start}-",
153
- "section_heading": current_chunk_sections[0]["heading"] if current_chunk_sections else "",
154
- }
155
- )
156
- chunks.append(chunk)
157
-
158
- return chunks
159
-
160
- def _get_overlap_content(self, content: str, overlap_tokens: int) -> str:
161
-
162
- words = content.split()
163
- if len(words) <= overlap_tokens:
164
- return content
165
- return " ".join(words[-overlap_tokens:])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py CHANGED
@@ -13,11 +13,9 @@ class Settings(BaseSettings):
13
  PROCESSED_DOCS_DIR: Path = BASE_DIR / "processed_docs"
14
  VECTOR_DB_DIR: Path = BASE_DIR / "kb" / "vector_db"
15
 
16
- # Chunking
17
  CHUNK_SIZE: int = 1200
18
  CHUNK_OVERLAP_RATIO: float = 0.12
19
  MIN_CHUNK_SIZE: int = 250
20
- DROP_FRONT_MATTER_PAGES: int = 30
21
 
22
  # Retrieval
23
  RETRIEVAL_TOP_K: int = 20
@@ -38,27 +36,125 @@ class Settings(BaseSettings):
38
 
39
  # APIs
40
  USDA_API_KEY: str
 
 
41
  LANGSMITH_API_KEY: str
42
 
43
  # Logging
44
  LOG_LEVEL: str = "INFO"
45
-
46
  # MER Multipliers
47
- MER_MULTIPLIERS: Dict[str, Dict[str, float]] = {
48
  "dog": {
49
  "adult_intact": 1.8,
50
  "adult_neutered": 1.6,
 
51
  "puppy_under_4m": 3.0,
52
  "puppy_over_4m": 2.0,
 
 
 
 
53
  },
54
  "cat": {
55
  "adult_intact": 1.4,
56
  "adult_neutered": 1.2,
 
57
  "kitten": 2.5,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  }
59
  }
60
 
61
- # Nutrition Rules
62
  NUTRITION_RULES: Dict[str, Dict[str, Any]] = {
63
  "senior_adjustment": {
64
  "condition": {"life_stage": "senior"},
 
13
  PROCESSED_DOCS_DIR: Path = BASE_DIR / "processed_docs"
14
  VECTOR_DB_DIR: Path = BASE_DIR / "kb" / "vector_db"
15
 
 
16
  CHUNK_SIZE: int = 1200
17
  CHUNK_OVERLAP_RATIO: float = 0.12
18
  MIN_CHUNK_SIZE: int = 250
 
19
 
20
  # Retrieval
21
  RETRIEVAL_TOP_K: int = 20
 
36
 
37
  # APIs
38
  USDA_API_KEY: str
39
+ USDA_BASE_URL: str = "https://api.nal.usda.gov/fdc/v1"
40
+ USDA_TIMEOUT: int = 20
41
  LANGSMITH_API_KEY: str
42
 
43
  # Logging
44
  LOG_LEVEL: str = "INFO"
45
+
46
  # MER Multipliers
47
+ ENERGY_MULTIPLIERS: Dict[str, Dict[str, float]] = {
48
  "dog": {
49
  "adult_intact": 1.8,
50
  "adult_neutered": 1.6,
51
+ "adult_obesity_prone": 1.4,
52
  "puppy_under_4m": 3.0,
53
  "puppy_over_4m": 2.0,
54
+ "pregnant": 1.8,
55
+ "lactating_peak": 3.0,
56
+ "senior_active": 1.6,
57
+ "senior_sedentary": 1.4,
58
  },
59
  "cat": {
60
  "adult_intact": 1.4,
61
  "adult_neutered": 1.2,
62
+ "adult_obesity_prone": 1.0,
63
  "kitten": 2.5,
64
+ "pregnant": 1.4,
65
+ "lactating_peak": 2.5,
66
+ "senior_active": 1.2,
67
+ "senior_sedentary": 1.1,
68
+ }
69
+ }
70
+
71
+ MACRONUTRIENT_TARGETS: Dict[str, Dict[str, Dict[str, float]]] = {
72
+ "dog": {
73
+ "adult_maintenance": {
74
+ "protein_pct": 20.0,
75
+ "fat_pct": 25.0,
76
+ "min_protein_pct": 18.0,
77
+ "max_protein_pct": 35.0,
78
+ "min_fat_pct": 20.0,
79
+ "max_fat_pct": 40.0,
80
+ },
81
+ "puppy_growth": {
82
+ "protein_pct": 25.0,
83
+ "fat_pct": 30.0,
84
+ "min_protein_pct": 22.5,
85
+ "max_protein_pct": 35.0,
86
+ "min_fat_pct": 25.0,
87
+ "max_fat_pct": 45.0,
88
+ },
89
+ "senior": {
90
+ "protein_pct": 22.0,
91
+ "fat_pct": 20.0,
92
+ "min_protein_pct": 20.0,
93
+ "max_protein_pct": 30.0,
94
+ "min_fat_pct": 15.0,
95
+ "max_fat_pct": 30.0,
96
+ },
97
+ },
98
+ "cat": {
99
+ "adult_maintenance": {
100
+ "protein_pct": 30.0,
101
+ "fat_pct": 30.0,
102
+ "min_protein_pct": 26.0,
103
+ "max_protein_pct": 45.0,
104
+ "min_fat_pct": 22.0,
105
+ "max_fat_pct": 40.0,
106
+ },
107
+ "kitten_growth": {
108
+ "protein_pct": 35.0,
109
+ "fat_pct": 35.0,
110
+ "min_protein_pct": 30.0,
111
+ "max_protein_pct": 50.0,
112
+ "min_fat_pct": 25.0,
113
+ "max_fat_pct": 45.0,
114
+ },
115
+ "senior": {
116
+ "protein_pct": 32.0,
117
+ "fat_pct": 25.0,
118
+ "min_protein_pct": 28.0,
119
+ "max_protein_pct": 40.0,
120
+ "min_fat_pct": 20.0,
121
+ "max_fat_pct": 35.0,
122
+ },
123
+ }
124
+ }
125
+
126
+ ENERGY_DENSITIES: Dict[str, float] = {
127
+ "protein": 4.0,
128
+ "carbohydrate": 4.0,
129
+ "fat": 9.0,
130
+ }
131
+
132
+ DIGESTIBILITY_FACTOR: float = 0.9
133
+
134
+ WATER_ML_PER_KCAL: float = 1.0
135
+
136
+ WATER_MULTIPLIERS: Dict[str, float] = {
137
+ "lactating": 2.0,
138
+ "pregnant": 1.5,
139
+ "puppy": 1.5,
140
+ "kitten": 1.5,
141
+ "senior": 1.2,
142
+ "high_activity": 1.5,
143
+ }
144
+
145
+ WEIGHT_RANGES: Dict[str, Dict[str, tuple]] = {
146
+ "dog": {
147
+ "toy": (0.5, 5.0),
148
+ "small": (5.0, 10.0),
149
+ "medium": (10.0, 25.0),
150
+ "large": (25.0, 45.0),
151
+ "giant": (45.0, 90.0),
152
+ },
153
+ "cat": {
154
+ "all": (2.0, 12.0),
155
  }
156
  }
157
 
 
158
  NUTRITION_RULES: Dict[str, Dict[str, Any]] = {
159
  "senior_adjustment": {
160
  "condition": {"life_stage": "senior"},
kb/document_processor.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import List, Dict, Any
3
+ from pathlib import Path
4
+
5
+ from langchain_community.document_loaders import PyMuPDFLoader
6
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
7
+ from langchain.schema import Document
8
+
9
+ from config import settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class DocumentProcessor:
15
+
16
+ def __init__(self):
17
+ """Initialize document processor with configured text splitter."""
18
+ self.text_splitter = RecursiveCharacterTextSplitter(
19
+ chunk_size=settings.CHUNK_SIZE,
20
+ chunk_overlap=int(settings.CHUNK_SIZE * settings.CHUNK_OVERLAP_RATIO),
21
+ length_function=len,
22
+ separators=["\n\n", "\n", ". ", " ", ""],
23
+ is_separator_regex=False,
24
+ )
25
+ logger.debug(
26
+ f"Initialized DocumentProcessor: chunk_size={settings.CHUNK_SIZE}, "
27
+ f"overlap={int(settings.CHUNK_SIZE * settings.CHUNK_OVERLAP_RATIO)}"
28
+ )
29
+
30
+ @staticmethod
31
+ def create_processor() -> "DocumentProcessor":
32
+ """Factory method to create processor instance."""
33
+ return DocumentProcessor()
34
+
35
+ def process_pdf(self, pdf_path: Path) -> List[Document]:
36
+
37
+ logger.info(f"Processing PDF: {pdf_path.name}")
38
+
39
+ try:
40
+ loader = PyMuPDFLoader(str(pdf_path))
41
+ raw_docs = loader.load()
42
+
43
+ logger.info(f"Loaded {len(raw_docs)} pages from {pdf_path.name}")
44
+
45
+ chunks = self.text_splitter.split_documents(raw_docs)
46
+
47
+ logger.info(f"Split into {len(chunks)} chunks from {pdf_path.name}")
48
+
49
+ for i, chunk in enumerate(chunks):
50
+ chunk.metadata = {
51
+ "chunk_id": f"{pdf_path.stem}_{i}",
52
+ "source_file": pdf_path.name,
53
+ "page": chunk.metadata.get("page", 0), # Preserve page number
54
+ "species": self._extract_species(chunk.page_content, pdf_path.name),
55
+ "topic": self._extract_topic(chunk.page_content),
56
+ }
57
+
58
+ logger.info(f"Created {len(chunks)} enriched chunks from {pdf_path.name}")
59
+
60
+ return chunks
61
+
62
+ except Exception as e:
63
+ logger.error(f"Failed to process {pdf_path.name}: {e}")
64
+ raise
65
+
66
+ def _extract_species(self, content: str, filename: str) -> str:
67
+
68
+ content_lower = content.lower()
69
+ filename_lower = filename.lower()
70
+
71
+ has_dog = (
72
+ "dog" in content_lower or
73
+ "canine" in content_lower or
74
+ "dog" in filename_lower or
75
+ "puppy" in content_lower
76
+ )
77
+ has_cat = (
78
+ "cat" in content_lower or
79
+ "feline" in content_lower or
80
+ "cat" in filename_lower or
81
+ "kitten" in content_lower
82
+ )
83
+
84
+ if has_dog and has_cat:
85
+ return "both"
86
+ elif has_dog:
87
+ return "dog"
88
+ elif has_cat:
89
+ return "cat"
90
+ else:
91
+ return "general"
92
+
93
+ def _extract_topic(self, content: str) -> str:
94
+
95
+ content_lower = content.lower()
96
+
97
+ topics = {
98
+ "nutrition": ["nutrient", "protein", "vitamin", "mineral", "diet", "energy", "calorie"],
99
+ "feeding": ["feed", "meal", "portion", "schedule", "frequency"],
100
+ "health": ["disease", "condition", "medical", "health", "disorder", "illness"],
101
+ "safety": ["toxic", "danger", "avoid", "harmful", "poisonous", "forbidden"],
102
+ "pregnancy": ["pregnant", "lactating", "gestation", "nursing", "reproduction"],
103
+ "growth": ["puppy", "kitten", "growth", "weaning", "development"],
104
+ "weight": ["weight", "obesity", "overweight", "underweight", "bcs"],
105
+ "senior": ["senior", "geriatric", "aging", "elderly", "old"],
106
+ }
107
+
108
+ for topic, keywords in topics.items():
109
+ if any(kw in content_lower for kw in keywords):
110
+ return topic
111
+
112
+ return "general"
113
+
114
+ def process_all_pdfs(self, pdf_dir: Path) -> List[Document]:
115
+
116
+ pdf_files = sorted(pdf_dir.glob("*.pdf"))
117
+
118
+ if not pdf_files:
119
+ raise ValueError(f"No PDF files found in {pdf_dir}")
120
+
121
+ logger.info(f"Found {len(pdf_files)} PDF files to process")
122
+
123
+ all_documents = []
124
+ failed_files = []
125
+
126
+ for pdf_path in pdf_files:
127
+ try:
128
+ docs = self.process_pdf(pdf_path)
129
+ all_documents.extend(docs)
130
+ except Exception as e:
131
+ logger.error(f"Failed to process {pdf_path.name}: {e}")
132
+ failed_files.append(pdf_path.name)
133
+ continue
134
+
135
+ if failed_files:
136
+ logger.warning(f"Failed to process {len(failed_files)} files: {failed_files}")
137
+
138
+ logger.info(
139
+ f"Successfully processed {len(pdf_files) - len(failed_files)}/{len(pdf_files)} PDFs, "
140
+ f"total {len(all_documents)} chunks"
141
+ )
142
+
143
+ return all_documents
kb/processed_docs/650285654-Nutrient-Requirements-of-Dogs-and-Cats_raw_extraction.json DELETED
The diff for this file is too large to render. See raw diff
 
kb/processed_docs/chunk_previews/650285654-Nutrient-Requirements-of-Dogs-and-Cats_chunks.txt DELETED
The diff for this file is too large to render. See raw diff
 
kb/usda_client.py CHANGED
@@ -1,71 +1,157 @@
1
  """
2
- USDA API client for food data retrieval.
3
  """
4
  import logging
5
- import time
6
- import json
7
- from typing import Dict, List, Optional
8
- from pathlib import Path
9
  import requests
 
 
10
 
11
  from config import settings
12
- from schema import USDAFoodItem, USDAFoodNutrients, USDAFoodData
13
 
14
  logger = logging.getLogger(__name__)
15
 
16
- class USDAClient:
17
-
18
- @staticmethod
19
- def create_client() -> "USDAClient":
20
- return USDAClient()
21
 
 
 
22
  def __init__(self):
 
23
  self.api_key = settings.USDA_API_KEY
24
- self.base_url = "https://api.nal.usda.gov/fdc/v1"
25
- self.cache_dir = settings.CACHE_DIR / "usda"
26
- self.cache_dir.mkdir(parents=True, exist_ok=True)
27
-
28
- if not self.api_key:
29
- raise ValueError("USDA_API_KEY missing")
30
-
31
  self.session = requests.Session()
32
- self.timeout = 20
33
-
34
- def search_foods(self, query: str, limit: int = 10) -> Dict:
35
- url = f"{self.base_url}/foods/search"
36
- params = {
37
- "api_key": self.api_key,
38
- "query": query,
39
- "pageSize": limit,
40
- }
41
- r = self.session.get(url, params=params, timeout=self.timeout)
42
- r.raise_for_status()
43
- return r.json()
44
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def get_food_details(self, fdc_id: int) -> Optional[USDAFoodData]:
46
- url = f"{self.base_url}/food/{fdc_id}"
47
- params = {"api_key": self.api_key}
48
- r = self.session.get(url, params=params, timeout=self.timeout)
49
- r.raise_for_status()
50
- return self._parse_food_data(r.json())
51
-
52
- def _parse_food_data(self, data: Dict) -> USDAFoodData:
53
- food_item = USDAFoodItem(
54
- fdc_id=data["fdcId"],
55
- description=data["description"],
56
- brand_owner=data.get("brandOwner"),
57
- ingredients=data.get("ingredients"),
58
- )
59
-
60
- nutrients = []
61
- for n in data.get("foodNutrients", []):
62
- nutrients.append(
63
- USDAFoodNutrients(
64
- nutrient_id=n["nutrient"]["id"],
65
- nutrient_name=n["nutrient"]["name"],
66
- unit_name=n["nutrient"]["unitName"],
67
- value=n.get("amount", 0.0),
68
- )
69
  )
70
-
71
- return USDAFoodData(food_item=food_item, nutrients=nutrients)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ USDA FoodData Central API client.
3
  """
4
  import logging
 
 
 
 
5
  import requests
6
+ from typing import Optional, List, Dict, Any
7
+ from functools import lru_cache
8
 
9
  from config import settings
10
+ from schema import USDAFoodData, USDAFoodItem, USDANutrient
11
 
12
  logger = logging.getLogger(__name__)
13
 
 
 
 
 
 
14
 
15
+ class USDAClient:
16
+
17
  def __init__(self):
18
+ """Initialize USDA client with API credentials."""
19
  self.api_key = settings.USDA_API_KEY
20
+ self.base_url = settings.USDA_BASE_URL
21
+ self.timeout = settings.USDA_TIMEOUT
 
 
 
 
 
22
  self.session = requests.Session()
23
+
24
+ # In-memory cache for food details
25
+ self._cache: Dict[int, USDAFoodData] = {}
26
+
27
+ logger.debug(f"USDAClient initialized: base_url={self.base_url}")
28
+
29
+ @staticmethod
30
+ def create() -> "USDAClient":
31
+ """Factory method to create client."""
32
+ return USDAClient()
33
+
34
+ def search_foods(
35
+ self,
36
+ query: str,
37
+ limit: int = 10,
38
+ data_type: Optional[List[str]] = None
39
+ ) -> List[Dict[str, Any]]:
40
+
41
+ try:
42
+ url = f"{self.base_url}/foods/search"
43
+ params = {
44
+ "api_key": self.api_key,
45
+ "query": query,
46
+ "pageSize": limit,
47
+ }
48
+
49
+ if data_type:
50
+ params["dataType"] = ",".join(data_type)
51
+
52
+ logger.debug(f"Searching USDA for: {query}")
53
+ response = self.session.get(url, params=params, timeout=self.timeout)
54
+ response.raise_for_status()
55
+
56
+ data = response.json()
57
+ foods = data.get("foods", [])
58
+
59
+ logger.info(f"Found {len(foods)} results for '{query}'")
60
+ return foods
61
+
62
+ except requests.exceptions.RequestException as e:
63
+ logger.error(f"USDA search failed for '{query}': {e}")
64
+ return []
65
+
66
  def get_food_details(self, fdc_id: int) -> Optional[USDAFoodData]:
67
+
68
+ if fdc_id in self._cache:
69
+ logger.debug(f"Using cached data for FDC ID {fdc_id}")
70
+ return self._cache[fdc_id]
71
+
72
+ try:
73
+ url = f"{self.base_url}/food/{fdc_id}"
74
+ params = {"api_key": self.api_key}
75
+
76
+ logger.debug(f"Fetching USDA food details: FDC ID {fdc_id}")
77
+ response = self.session.get(url, params=params, timeout=self.timeout)
78
+ response.raise_for_status()
79
+
80
+ data = response.json()
81
+
82
+ food_item = USDAFoodItem(
83
+ fdc_id=data["fdcId"],
84
+ description=data.get("description", "Unknown"),
85
+ brand_owner=data.get("brandOwner"),
86
+ ingredients=data.get("ingredients"),
 
 
 
87
  )
88
+
89
+ # Extract nutrients
90
+ nutrients = []
91
+ for nutrient_data in data.get("foodNutrients", []):
92
+ if "nutrient" in nutrient_data:
93
+ nutrients.append(USDANutrient(
94
+ nutrient_id=nutrient_data["nutrient"]["id"],
95
+ nutrient_name=nutrient_data["nutrient"]["name"],
96
+ unit_name=nutrient_data["nutrient"]["unitName"],
97
+ value=nutrient_data.get("amount", 0.0),
98
+ ))
99
+
100
+ food_data = USDAFoodData(
101
+ food_item=food_item,
102
+ nutrients=nutrients,
103
+ )
104
+
105
+ # Cache result
106
+ self._cache[fdc_id] = food_data
107
+ logger.info(f"Retrieved {len(nutrients)} nutrients for FDC ID {fdc_id}")
108
+
109
+ return food_data
110
+
111
+ except requests.exceptions.RequestException as e:
112
+ logger.error(f"Failed to get food details for FDC ID {fdc_id}: {e}")
113
+ return None
114
+
115
+ def get_nutrition_for_ingredient(
116
+ self,
117
+ ingredient: str,
118
+ quantity_grams: Optional[float] = 100.0
119
+ ) -> Optional[Dict[str, Any]]:
120
+
121
+ search_results = self.search_foods(ingredient, limit=1)
122
+
123
+ if not search_results:
124
+ logger.warning(f"No USDA results for '{ingredient}'")
125
+ return None
126
+
127
+ top_result = search_results[0]
128
+ fdc_id = top_result["fdcId"]
129
+ description = top_result["description"]
130
+
131
+ food_data = self.get_food_details(fdc_id)
132
+
133
+ if not food_data:
134
+ return None
135
+
136
+ scale_factor = quantity_grams / 100.0
137
+
138
+ nutrients_map = {n.nutrient_name: n.value for n in food_data.nutrients}
139
+
140
+ calories = nutrients_map.get("Energy", 0.0) * scale_factor
141
+ protein = nutrients_map.get("Protein", 0.0) * scale_factor
142
+ fat = nutrients_map.get("Total lipid (fat)", 0.0) * scale_factor
143
+ carbs = nutrients_map.get("Carbohydrate, by difference", 0.0) * scale_factor
144
+
145
+ return {
146
+ "calories": round(calories, 2),
147
+ "protein_g": round(protein, 2),
148
+ "fat_g": round(fat, 2),
149
+ "carbs_g": round(carbs, 2),
150
+ "source": "USDA",
151
+ "fdc_id": fdc_id,
152
+ "description": description,
153
+ }
154
+
155
+ def clear_cache(self):
156
+ self._cache.clear()
157
+ logger.info("USDA cache cleared")
kb/vector_db/vector_index.faiss CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e2a6749cb5685d0395126c5f911004959556f9bedf90def552fe2bd2fb99b3dd
3
- size 2333466
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0448a5bff20379241e506aac936fc075e0c6ac8768aaf5562e4203c7a6e652bb
3
+ size 5614102
kb/vector_db/vector_index_metadata.json CHANGED
The diff for this file is too large to render. See raw diff
 
llm/chains.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangChain multi-chain architecture for meal planning.
3
+ """
4
+ import logging
5
+ from typing import Dict, Any, List
6
+
7
+ from langchain_core.prompts import PromptTemplate
8
+ from langchain_core.runnables import RunnableParallel, RunnableLambda
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class MealPlanningChains:
14
+
15
+ def __init__(self, llm):
16
+
17
+ self.llm = llm
18
+ logger.debug("MealPlanningChains initialized")
19
+
20
+ @staticmethod
21
+ def create(llm) -> "MealPlanningChains":
22
+ """Factory method to create chains."""
23
+ return MealPlanningChains(llm)
24
+
25
+ def create_unified_chain(self, prompt_template: PromptTemplate):
26
+
27
+ return prompt_template | self.llm
28
+
29
+ def create_scheduling_chain(self):
30
+
31
+ logger.warning("Scheduling chain not yet implemented, using unified chain")
32
+ return None
33
+
34
+
35
+ def create_meal_options_chain(self):
36
+
37
+ logger.warning("Meal options chain not yet implemented, using unified chain")
38
+ return None
39
+
40
+ def create_validation_chain(self):
41
+
42
+ logger.warning("Validation chain not yet implemented, requires Phase 5 & 6")
43
+ return None
44
+
45
+ def create_parallel_options_chain(self, meal_slots: List[str]):
46
+
47
+ logger.warning("Parallel chain not yet implemented")
48
+ return None
49
+ =
50
+
51
+ def split_meal_plan_sections(plan_text: str) -> Dict[str, str]:
52
+
53
+ logger.debug("Using postprocessor for section splitting (Phase 6 will replace)")
54
+ return {}
55
+
56
+
57
+ def calculate_meal_nutrition(ingredients: List[str], quantities: List[float]) -> Dict[str, float]:
58
+
59
+ logger.warning("USDA lookup not yet implemented, returning placeholder")
60
+ return {
61
+ "calories": 0.0,
62
+ "protein_g": 0.0,
63
+ "fat_g": 0.0,
64
+ "carbs_g": 0.0
65
+ }
llm/output_parsers.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic output parsers for structured LLM responses.
3
+
4
+ """
5
+ import logging
6
+ from typing import Optional
7
+
8
+ from langchain_core.output_parsers import PydanticOutputParser
9
+ from langchain_core.exceptions import OutputParserException
10
+
11
+ from schema import NutritionPlanOutput
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class NutritionPlanParser:
17
+
18
+ def __init__(self):
19
+ """Initialize parser with NutritionPlanOutput schema."""
20
+ self.parser = PydanticOutputParser(pydantic_object=NutritionPlanOutput)
21
+ logger.debug("NutritionPlanParser initialized")
22
+
23
+ @staticmethod
24
+ def create() -> "NutritionPlanParser":
25
+ """Factory method to create parser."""
26
+ return NutritionPlanParser()
27
+
28
+ def get_format_instructions(self) -> str:
29
+
30
+ return self.parser.get_format_instructions()
31
+
32
+ def parse(self, llm_output: str) -> Optional[NutritionPlanOutput]:
33
+
34
+ try:
35
+ logger.debug("Parsing LLM output with Pydantic...")
36
+ parsed = self.parser.parse(llm_output)
37
+ logger.info("Successfully parsed nutrition plan")
38
+ return parsed
39
+
40
+ except OutputParserException as e:
41
+ logger.error(f"Failed to parse LLM output: {e}")
42
+ # Log the problematic output for debugging
43
+ logger.debug(f"Problematic output: {llm_output[:500]}...")
44
+ raise
45
+
46
+ except Exception as e:
47
+ logger.error(f" Unexpected parsing error: {e}")
48
+ raise
49
+
50
+ def parse_with_retry(
51
+ self,
52
+ llm_output: str,
53
+ llm_client=None,
54
+ max_retries: int = 2
55
+ ) -> Optional[NutritionPlanOutput]:
56
+
57
+ try:
58
+ # Try initial parse
59
+ return self.parse(llm_output)
60
+
61
+ except OutputParserException as e:
62
+ if llm_client and max_retries > 0:
63
+ logger.warning(f"Parse failed, attempting retry with LLM fix...")
64
+
65
+ pass
66
+
67
+ logger.error("Parse failed and no retry available")
68
+ return None
69
+
70
+ class SimpleNutritionParser:
71
+
72
+ @staticmethod
73
+ def extract_text_plan(llm_output: str) -> dict:
74
+
75
+ logger.warning("Using fallback text extraction (structured parse failed)")
76
+
77
+ lines = [line.strip() for line in llm_output.splitlines() if line.strip()]
78
+
79
+ return {
80
+ "text_lines": lines,
81
+ "source": "fallback_extraction",
82
+ "warning": "Structured parsing failed, using text fallback"
83
+ }
llm/prompt_templates.py CHANGED
@@ -12,7 +12,7 @@ class PromptBuilder:
12
 
13
  INSTRUCTIONS:
14
 
15
- 1. Generate a meal plan with proper meals per day, distributed in percentage (for example, for 2 meals -> 50%-50%, for 3 meals ->33%-33%-33%, depending on the user input.)
16
 
17
  2. For EACH meal slot, generate AT LEAST TWO distinct HOME-COOKED OPTIONS.
18
  - Clearly label them as Option A and Option B.
@@ -23,71 +23,55 @@ class PromptBuilder:
23
 
24
  3. Include proper feeding instructions and safety notes, specific to the inputs.
25
 
26
- 4. Ensure the plan is safe and based on veterinary guidelines.
27
 
28
  5. Output in plain text format (human readable language) as specified.
29
 
30
  6. DO NOT repeat or echo any raw user input values in the output - treat them as internal context only.
31
 
32
- 7. Focus output on computed nutrition targets and the meal plan only.
33
 
34
- 8. STRICTLY follow this exact output structure - no deviations:
35
 
36
- PET NUTRITION PLAN
37
 
38
- DAILY NUTRITION TARGETS
39
-
40
- Energy Requirements
41
- RER: ___ kcal/day
42
- MER: ___ kcal/day
43
- Daily Calorie Target: ___ kcal/day
44
-
45
- Macronutrients (Approx.)
46
- Protein: ___ g/day
47
- Fat: ___ g/day
48
- Carbohydrates: ___ g/day
49
 
50
- Water Intake: ___ ml/day
 
 
51
 
52
- RECOMMENDED MEAL PLAN WITH FEEDING SCHEDULE
53
- Number of Meals: _______
54
- Meal Distribution: ________
55
-
56
- 8:00 AM — Morning Meal
57
 
58
  Option A
59
  Type: ________
60
  Calories: __________
61
- Ingredients: [list actual ingredients, no examples]
62
- Preparation: [list actual preparation steps]
63
 
64
  Option B
65
  Type: ________
66
  Calories: __________
67
- Ingredients: [list actual ingredients, no examples]
68
- Preparation: [list actual preparation steps]
69
 
70
  [Repeat for each meal slot]
71
 
72
- FEEDING INSTRUCTIONS
73
- [numbered pointers with actual instructions, max 5]
74
 
75
- FOOD CATEGORIES [return 3 numbered pointers, MAX 5 food items for each of the three categories.]
76
- Dangerous: [list]
77
- Avoid: [list]
78
- Safe: [list]
79
 
80
- SAFETY NOTES
81
  [numbered pointers, MAX 5]
82
 
83
- DISCLAIMER
84
  This nutrition plan is AI-generated guidance and not a medical prescription. Consult a veterinarian for medical conditions or major diet changes.
85
 
86
- 9. FORBID any generic/example language: NO "Example calculation", "hypothetical", "illustrative", "vague guidance", "non actionable text".
87
-
88
- 10. Use real, specific, actionable content only. Adapt dynamically based on retrieved knowledge and reasoning.
89
 
90
- 11. No hardcoded food logic - LLM reasoning handle the data context naturally.
91
 
92
  12. Ensure both Option A and Option B strictly avoid all allergies and diseases mentioned in the internal context.
93
 
@@ -100,18 +84,93 @@ class PromptBuilder:
100
  """
101
 
102
  self.generation_template = PromptTemplate(
103
- input_variables=["pet_profile", "nutrition_targets", "retrieved_context", "instructions"],
104
  template=
105
 
106
  """ You are an expert veterinary nutritionist. Generate a safe, explainable pet nutrition diet plan based on the following information.
107
 
108
  INTERNAL CONTEXT (DO NOT REPEAT IN OUTPUT): {pet_profile}
109
 
110
- NUTRITION TARGETS: {nutrition_targets}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
- RETRIEVED KNOWLEDGE: {retrieved_context}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
- {instructions}
115
  """
116
  )
117
 
@@ -133,11 +192,7 @@ class PromptBuilder:
133
  if pet_profile.diseases:
134
  query_parts.append(f"Diseases: {pet_profile.diseases}")
135
 
136
- query_parts.extend([
137
- f"RER: {nutrition_targets.get('rer', 'unknown')} kcal/day",
138
- f"MER: {nutrition_targets.get('mer', 'unknown')} kcal/day",
139
- "Nutrient requirements for dogs and cats"
140
- ])
141
 
142
  query = " ".join(query_parts)
143
  logger.info(f"Built retrieval query: {query}")
 
12
 
13
  INSTRUCTIONS:
14
 
15
+ 1. Generate a meal plan with proper meals per day. Determine the number of meals (typically 2-3 for adults, 3-4 for puppies/kittens) based on the pet's life stage, activity level, and veterinary context. Distribute calories appropriately (e.g., for 2 meals: 50%-50%, for 3 meals: 33%-33%-34%).
16
 
17
  2. For EACH meal slot, generate AT LEAST TWO distinct HOME-COOKED OPTIONS.
18
  - Clearly label them as Option A and Option B.
 
23
 
24
  3. Include proper feeding instructions and safety notes, specific to the inputs.
25
 
26
+ 4. Ensure the plan is safe and based on veterinary guidelines from the context.
27
 
28
  5. Output in plain text format (human readable language) as specified.
29
 
30
  6. DO NOT repeat or echo any raw user input values in the output - treat them as internal context only.
31
 
32
+ 7. Focus output on the meal plan only - nutrition targets are already provided separately.
33
 
34
+ 8. CRITICAL: DO NOT include any nutrition target values (Daily Calorie Target, Protein, Fat, Carbohydrates, Water) in the output. These are handled separately.
35
 
36
+ 9. STRICTLY follow this exact output structure - no deviations:
37
 
38
+ Pet Nutrition Plan
 
 
 
 
 
 
 
 
 
 
39
 
40
+ Recommended Meal Plan with Feeding Schedule
41
+ Number of Meals: [Determine from context: 2-4 meals based on life stage]
42
+ Meal Distribution: [Calculate % based on number of meals]
43
 
44
+ [Time] [Meal Name] (Approx. ___ kcal)
 
 
 
 
45
 
46
  Option A
47
  Type: ________
48
  Calories: __________
49
+ Ingredients: [list actual ingredients with quantities]
50
+ Preparation: [numbered steps]
51
 
52
  Option B
53
  Type: ________
54
  Calories: __________
55
+ Ingredients: [list actual ingredients with quantities]
56
+ Preparation: [numbered steps]
57
 
58
  [Repeat for each meal slot]
59
 
60
+ Feeding Instructions
61
+ [numbered pointers with actual instructions, max 7]
62
 
63
+ Food Categories
64
+ {food_categories}
 
 
65
 
66
+ Safety Notes
67
  [numbered pointers, MAX 5]
68
 
69
+ Disclaimer
70
  This nutrition plan is AI-generated guidance and not a medical prescription. Consult a veterinarian for medical conditions or major diet changes.
71
 
72
+ 10. FORBID any generic/example language: NO "Example calculation", "hypothetical", "illustrative", "vague guidance", "non actionable text".
 
 
73
 
74
+ 11. Use real, specific, actionable content only. Adapt dynamically based on retrieved knowledge and reasoning.
75
 
76
  12. Ensure both Option A and Option B strictly avoid all allergies and diseases mentioned in the internal context.
77
 
 
84
  """
85
 
86
  self.generation_template = PromptTemplate(
87
+ input_variables=["pet_profile", "daily_calorie_target", "daily_protein_g", "daily_fat_g", "daily_carbohydrates_g", "daily_water_ml", "retrieved_context"],
88
  template=
89
 
90
  """ You are an expert veterinary nutritionist. Generate a safe, explainable pet nutrition diet plan based on the following information.
91
 
92
  INTERNAL CONTEXT (DO NOT REPEAT IN OUTPUT): {pet_profile}
93
 
94
+ COMPUTED NUTRITION TARGETS (Use these exact values):
95
+ - Daily Calorie Target: {daily_calorie_target} kcal/day
96
+ - Protein: {daily_protein_g} g/day
97
+ - Fat: {daily_fat_g} g/day
98
+ - Carbohydrates: {daily_carbohydrates_g} g/day
99
+ - Water: {daily_water_ml} ml/day
100
+
101
+ RETRIEVED VETERINARY KNOWLEDGE: {retrieved_context}
102
+
103
+ INSTRUCTIONS:
104
+
105
+ 1. Generate a meal plan with proper meals per day. Determine the number of meals (typically 2-3 for adults, 3-4 for puppies/kittens) based on the pet's life stage, activity level, and veterinary context. Distribute calories appropriately (e.g., for 2 meals: 50%-50%, for 3 meals: 33%-33%-34%).
106
+
107
+ 2. For EACH meal slot, generate AT LEAST TWO distinct HOME-COOKED OPTIONS.
108
+ - Clearly label them as Option A and Option B.
109
+ - Each option must include its own Type, Calories, Ingredients, and Preparation.
110
+ - Both options must independently satisfy the nutrition intent of that meal.
111
+ - DO NOT include any commercial food, packaged food, or brand names anywhere.
112
+ - Pet owner must be able to choose either option freely.
113
+
114
+ 3. Include proper feeding instructions and safety notes, specific to the pet's profile.
115
+
116
+ 4. Ensure the plan is safe and based on veterinary guidelines from the context.
117
+
118
+ 5. Output in plain text format (human readable language) as specified.
119
+
120
+ 6. DO NOT repeat or echo any raw user input values in the output - treat them as internal context only.
121
+
122
+ 7. CRITICAL: DO NOT include RER (Resting Energy Requirement) or MER (Maintenance Energy Requirement) in the output. These are internal calculations only. Only show the Daily Calorie Target provided above.
123
+
124
+ 8. STRICTLY follow this exact output structure:
125
+
126
+ PET NUTRITION PLAN
127
+
128
+ DAILY NUTRITION TARGETS
129
+
130
+ Daily Calorie Target: {daily_calorie_target} kcal/day
131
 
132
+ Macronutrients
133
+ Protein: {daily_protein_g} g/day
134
+ Fat: {daily_fat_g} g/day
135
+ Carbohydrates: {daily_carbohydrates_g} g/day
136
+
137
+ Water Intake: {daily_water_ml} ml/day
138
+
139
+ RECOMMENDED MEAL PLAN WITH FEEDING SCHEDULE
140
+ Number of Meals: [Determine from context: 2-4 meals based on life stage]
141
+ Meal Distribution: [Calculate % based on number of meals]
142
+
143
+ [Time] — [Meal Name] (Approx. ___ kcal)
144
+
145
+ Option A
146
+ Type: ________
147
+ Calories: __________
148
+ Ingredients: [list actual ingredients with quantities]
149
+ Preparation: [numbered steps]
150
+
151
+ Option B
152
+ Type: ________
153
+ Calories: __________
154
+ Ingredients: [list actual ingredients with quantities]
155
+ Preparation: [numbered steps]
156
+
157
+ [Repeat for each meal slot]
158
+
159
+ FEEDING INSTRUCTIONS
160
+ [numbered pointers with actual instructions, max 7]
161
+
162
+ FOOD CATEGORIES
163
+ Dangerous: [list MAX 10 toxic foods specific to this pet type]
164
+ Avoid: [list MAX 10 foods to avoid based on pet's allergies/conditions]
165
+ Safe: [list MAX 10 safe foods appropriate for this pet]
166
+
167
+ SAFETY NOTES
168
+ [numbered pointers, MAX 5]
169
+
170
+ DISCLAIMER
171
+ This nutrition plan is AI-generated guidance and not a medical prescription. Consult a veterinarian for medical conditions or major diet changes.
172
 
173
+ Generate the nutrition plan now.
174
  """
175
  )
176
 
 
192
  if pet_profile.diseases:
193
  query_parts.append(f"Diseases: {pet_profile.diseases}")
194
 
195
+ query_parts.append("Nutrient requirements for dogs and cats")
 
 
 
 
196
 
197
  query = " ".join(query_parts)
198
  logger.info(f"Built retrieval query: {query}")
load_kb.py CHANGED
@@ -1,74 +1,124 @@
1
  """
2
- Knowledge base loading and processing.
 
 
 
 
 
 
 
 
 
 
3
  """
4
  import logging
5
  from pathlib import Path
6
- from typing import Dict, List, Tuple
7
- import numpy as np
8
  import time
9
 
10
  from config import settings
11
- from kb.pdf_ingestor import create_pdf_ingestor
12
- from chunker import SemanticChunker, DocumentChunk
13
  from rag.embed import EmbeddingLayer
14
  from rag.vectorstore import VectorStore
15
 
16
  logger = logging.getLogger(__name__)
17
 
18
- class KnowledgeBaseLoader:
19
-
20
- @staticmethod
21
- def create_loader() -> "KnowledgeBaseLoader":
22
- return KnowledgeBaseLoader()
23
 
 
 
24
  def __init__(self):
 
25
  self.pdf_dir = settings.RAW_DATA_DIR / "pdf"
26
-
27
- self.ingestor = create_pdf_ingestor(settings)
28
- self.chunker = SemanticChunker.create_chunker()
29
  self.embedder = EmbeddingLayer.create_embedder()
30
  self.vector_store = VectorStore.create_vectorstore()
31
-
 
 
 
 
 
 
 
32
  def load_knowledge_base(self, force_rebuild: bool = False) -> Dict:
 
33
  start = time.time()
34
-
35
  if not force_rebuild and self.vector_store.load():
36
- logger.info("Knowledge base loaded successfully from existing index")
37
  return {
38
  "status": "loaded",
39
  "chunks": len(self.vector_store.records),
40
  "time_sec": round(time.time() - start, 2),
41
  }
42
-
43
- logger.info("Rebuilding knowledge base from data")
44
-
45
- logger.info(f"Looking for data in {self.pdf_dir}")
46
- pdf_files = list(self.pdf_dir.glob("*.pdf"))
47
- logger.info(f"Found {len(pdf_files)} PDF files: {[str(p) for p in pdf_files]}")
48
  if not pdf_files:
49
- raise ValueError(f"No data found in {self.pdf_dir}")
50
-
51
- all_chunks: List[DocumentChunk] = []
52
-
53
  for pdf in pdf_files:
54
- docs = self.ingestor.ingest_pdf(pdf)
55
- content = "\n\n".join([d.text for d in docs])
56
- chunks, _ = self.chunker.chunk_document(content, pdf.name)
57
- all_chunks.extend(chunks)
58
-
59
- texts = [c.content for c in all_chunks]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  embeddings = self.embedder.embed_texts(texts)
61
-
62
- metadatas = [c.metadata.model_dump() for c in all_chunks]
 
 
63
  self.vector_store.add(embeddings, texts, metadatas)
64
  self.vector_store.persist()
65
-
66
- logger.info(f"Knowledge base rebuilt: {len(all_chunks)} chunks")
67
-
 
 
 
 
 
68
  return {
69
  "status": "rebuilt",
70
  "pdfs": len(pdf_files),
71
- "chunks": len(all_chunks),
72
- "vectors": len(all_chunks),
73
- "time_sec": round(time.time() - start, 2),
74
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Knowledge base loading with LangChain document processing.
3
+
4
+ This file orchestrates:
5
+ 1. PDF loading and chunking (via DocumentProcessor)
6
+ 2. Embedding generation (via EmbeddingLayer)
7
+ 3. Vector store persistence (via VectorStore)
8
+
9
+ Key improvements over old version:
10
+ - Uses LangChain document processing
11
+ - Processes ALL PDF content
12
+ - Incremental loading support
13
  """
14
  import logging
15
  from pathlib import Path
16
+ from typing import Dict, List
 
17
  import time
18
 
19
  from config import settings
20
+ from kb.document_processor import DocumentProcessor
 
21
  from rag.embed import EmbeddingLayer
22
  from rag.vectorstore import VectorStore
23
 
24
  logger = logging.getLogger(__name__)
25
 
 
 
 
 
 
26
 
27
+ class KnowledgeBaseLoader:
28
+
29
  def __init__(self):
30
+ """Initialize KB loader with required components."""
31
  self.pdf_dir = settings.RAW_DATA_DIR / "pdf"
32
+ self.processor = DocumentProcessor.create_processor()
 
 
33
  self.embedder = EmbeddingLayer.create_embedder()
34
  self.vector_store = VectorStore.create_vectorstore()
35
+
36
+ logger.debug(f"KnowledgeBaseLoader initialized with pdf_dir={self.pdf_dir}")
37
+
38
+ @staticmethod
39
+ def create_loader() -> "KnowledgeBaseLoader":
40
+ """Factory method to create loader instance."""
41
+ return KnowledgeBaseLoader()
42
+
43
  def load_knowledge_base(self, force_rebuild: bool = False) -> Dict:
44
+
45
  start = time.time()
46
+
47
  if not force_rebuild and self.vector_store.load():
48
+ logger.info(" Knowledge base loaded from existing vector store")
49
  return {
50
  "status": "loaded",
51
  "chunks": len(self.vector_store.records),
52
  "time_sec": round(time.time() - start, 2),
53
  }
54
+
55
+ # Rebuild from scratch
56
+ logger.info("Rebuilding knowledge base from PDFs...")
57
+
58
+ # Find all PDFs
59
+ pdf_files = sorted(self.pdf_dir.glob("*.pdf"))
60
  if not pdf_files:
61
+ raise ValueError(f"No PDF files found in {self.pdf_dir}")
62
+
63
+ logger.info(f"Found {len(pdf_files)} PDF files to process:")
 
64
  for pdf in pdf_files:
65
+ logger.info(f" - {pdf.name}")
66
+
67
+ # Process all PDFs with DocumentProcessor
68
+ try:
69
+ all_documents = self.processor.process_all_pdfs(self.pdf_dir)
70
+ except Exception as e:
71
+ logger.error(f"Failed to process PDFs: {e}")
72
+ raise
73
+
74
+ if not all_documents:
75
+ raise ValueError("No documents extracted from PDFs")
76
+
77
+ logger.info(f"Total documents created: {len(all_documents)}")
78
+
79
+ self._log_metadata_stats(all_documents)
80
+
81
+ texts = [doc.page_content for doc in all_documents]
82
+ metadatas = [doc.metadata for doc in all_documents]
83
+
84
+ logger.info("Generating embeddings...")
85
  embeddings = self.embedder.embed_texts(texts)
86
+ logger.info(f"Generated {len(embeddings)} embeddings")
87
+
88
+ # Store in vector database
89
+ logger.info("Storing in vector database...")
90
  self.vector_store.add(embeddings, texts, metadatas)
91
  self.vector_store.persist()
92
+ logger.info(f"Vector store persisted to {settings.VECTOR_DB_DIR}")
93
+
94
+ elapsed = round(time.time() - start, 2)
95
+ logger.info(
96
+ f"Knowledge base rebuilt successfully: "
97
+ f"{len(all_documents)} chunks from {len(pdf_files)} PDFs in {elapsed}s"
98
+ )
99
+
100
  return {
101
  "status": "rebuilt",
102
  "pdfs": len(pdf_files),
103
+ "chunks": len(all_documents),
104
+ "time_sec": elapsed,
 
105
  }
106
+
107
+ def _log_metadata_stats(self, documents: List):
108
+ """
109
+ Log statistics about document metadata.
110
+
111
+ Useful for verifying metadata extraction quality.
112
+ """
113
+ from collections import Counter
114
+
115
+ species_counts = Counter(doc.metadata.get("species", "unknown") for doc in documents)
116
+ logger.info(f"Species distribution: {dict(species_counts)}")
117
+
118
+ topic_counts = Counter(doc.metadata.get("topic", "unknown") for doc in documents)
119
+ logger.info(f"Topic distribution: {dict(topic_counts)}")
120
+
121
+ source_counts = Counter(doc.metadata.get("source_file", "unknown") for doc in documents)
122
+ logger.info(f"Chunks per file:")
123
+ for source, count in sorted(source_counts.items()):
124
+ logger.info(f" - {source}: {count} chunks")
main.py DELETED
@@ -1,182 +0,0 @@
1
- import logging
2
- import json
3
-
4
- from config import settings
5
- from input_handler import InputHandler
6
- from load_kb import KnowledgeBaseLoader
7
- from nutrition_engine import NutritionEngine
8
- from postprocess import PostProcessor
9
- from rules_engine import RulesEngine
10
- from rag.retriever import KnowledgeRetriever
11
- from llm.gemini_client import GeminiClient, LLMResponse
12
- from llm.prompt_templates import PromptBuilder
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
- def setup_logging():
17
- logging.getLogger("sentence_transformers").setLevel(logging.WARNING)
18
- logging.getLogger("tqdm").setLevel(logging.WARNING)
19
- logging.getLogger("httpx").setLevel(logging.WARNING)
20
-
21
- logger = logging.getLogger()
22
- logger.setLevel(logging.INFO)
23
-
24
- for handler in logger.handlers[:]:
25
- logger.removeHandler(handler)
26
-
27
- file_handler = logging.FileHandler("nutrition_planner.log")
28
- file_handler.setLevel(logging.INFO)
29
- file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
30
-
31
- console_handler = logging.StreamHandler()
32
- console_handler.setLevel(logging.WARNING)
33
- console_handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s"))
34
-
35
- logger.addHandler(file_handler)
36
- logger.addHandler(console_handler)
37
-
38
-
39
- def collect_user_input() -> dict:
40
- print("\n" + "=" * 60)
41
- print("PET NUTRITION DIET PLANNER")
42
- print("=" * 60)
43
- print("Hello! I'm here to help create a personalized nutrition plan for your pet.")
44
- print("Please answer the following questions about your furry friend.\n")
45
-
46
- pet_type = input("What type of pet do you have? (dog/cat): ").strip()
47
- breed = input("What is your pet's breed? ").strip()
48
- gender = input("Is your pet male or female? (male/female): ").strip()
49
-
50
- pregnant_lactating = None
51
- if gender.lower() == "female":
52
- pregnant_lactating = input("Is your pet pregnant or lactating? (choose one: none/pregnant/lactating): ").strip() or None
53
-
54
- weight_kg = float(input("How much does your pet weigh in kilograms? ").strip())
55
- activity_level = input("How would you describe your pet's activity level? (low/moderate/high): ").strip()
56
- life_stage = input("What is your pet's life stage? (puppy/adult/senior for dogs, kitten/adult/senior for cats): ").strip()
57
- bcs = int(input("On a scale of 1-9, what is your pet's body condition score?: ").strip())
58
- neutered_spayed = input("Has your pet been neutered or spayed? (yes/no): ").strip()
59
-
60
- allergies = input("Does your pet have any allergies? (leave blank if none): ").strip() or None
61
- diseases = input("Does your pet have any medical conditions or diseases? (leave blank if none): ").strip() or None
62
- diet_preference = input("What type of diet does your pet prefer? (vegetarian/non-vegetarian/mixed): ").strip()
63
- current_food_format = input("What format is your pet's current food? (home-cooked/dry/wet/mixed): ").strip()
64
-
65
- return {
66
- "pet_type": pet_type,
67
- "breed": breed,
68
- "gender": gender,
69
- "weight_kg": weight_kg,
70
- "activity_level": activity_level,
71
- "life_stage": life_stage,
72
- "bcs": bcs,
73
- "neutered_spayed": neutered_spayed,
74
- "pregnant_lactating": pregnant_lactating,
75
- "allergies": allergies,
76
- "diseases": diseases,
77
- "diet_preference": diet_preference,
78
- "current_food_format": current_food_format,
79
- }
80
-
81
-
82
- def main():
83
- setup_logging()
84
- logger.info("Starting Pet Nutrition Planner")
85
-
86
- # Step 1 — Collect and validate input
87
- raw_input = collect_user_input()
88
- input_handler = InputHandler.create_handler()
89
- pet_profile = input_handler.validate_and_parse(raw_input)
90
- if pet_profile is None:
91
- print("Invalid input. Please restart.")
92
- return
93
-
94
- nutrition_engine = NutritionEngine.create_engine()
95
- rules_engine = RulesEngine.create_engine()
96
-
97
- # Load knowledge base after input collection
98
- kb_loader = KnowledgeBaseLoader.create_loader()
99
- try:
100
- kb_result = kb_loader.load_knowledge_base(force_rebuild=False)
101
- except Exception as e:
102
- logger.warning(f"Failed to load knowledge base: {e}")
103
- logger.info("Attempting to rebuild knowledge base...")
104
- try:
105
- kb_result = kb_loader.load_knowledge_base(force_rebuild=True)
106
- except Exception as e2:
107
- logger.error(f"Failed to rebuild knowledge base: {e2}")
108
- print("Error: Knowledge base not available. Please ensure PDF files are in raw_data/pdf directory.")
109
- return
110
-
111
- retriever = KnowledgeRetriever.create()
112
- llm_client = GeminiClient.create()
113
- prompt_builder = PromptBuilder()
114
-
115
- # Step 2 — calculate nutrition targets
116
- nutrition_targets = nutrition_engine.calculate_nutrition_targets(pet_profile)
117
- rules_adjustments = rules_engine.apply_rules(pet_profile, nutrition_targets)
118
- safety_warnings = rules_engine.validate_plan_safety(pet_profile, nutrition_targets)
119
-
120
- if safety_warnings:
121
- print("\nSAFETY WARNINGS:")
122
- for warning in safety_warnings:
123
- print(f"- {warning}")
124
- print("Please consult a veterinarian for these concerns.\n")
125
-
126
- # Step 3 — Retrieve knowledge
127
- query = prompt_builder.build_retrieval_query(pet_profile, nutrition_targets)
128
- retrieved_chunks = retriever.retrieve(query, top_k=settings.RETRIEVAL_TOP_K)
129
-
130
- logger.info(
131
- f"Retrieved {len(retrieved_chunks)} chunks for generation"
132
- )
133
-
134
- # Step 4 - chaining
135
- chain = prompt_builder.generation_template | llm_client.llm
136
-
137
- inputs = {
138
- "pet_profile": f"- Pet: {pet_profile.pet_type}, {pet_profile.breed}, {pet_profile.gender}, {pet_profile.life_stage}, {pet_profile.weight_kg}kg, activity: {pet_profile.activity_level}, BCS: {pet_profile.bcs}, neutered/spayed: {pet_profile.neutered_spayed}, allergies: {pet_profile.allergies or 'none'}, diseases: {pet_profile.diseases or 'none'}, diet preference: {pet_profile.diet_preference}, current food format: {pet_profile.current_food_format}",
139
- "nutrition_targets": f"- RER: {nutrition_targets.get('rer', 'unknown')} kcal/day\n- MER: {nutrition_targets.get('mer', 'unknown')} kcal/day\n- Daily Calorie Target: {nutrition_targets.get('mer', 'unknown')} kcal/day",
140
- "retrieved_context": "\n\n".join([chunk["content"] for chunk in retrieved_chunks]), "instructions": prompt_builder.instructions
141
- }
142
- # Step 5 - response generation
143
- response = chain.invoke(inputs)
144
- text = response.content.strip()
145
-
146
- response = LLMResponse(success=True, json_text=text, error_message="")
147
-
148
- if not response.success:
149
- logger.error("Generation failed")
150
- print("\nSystem failed to generate a valid plan safely.")
151
- print(response.error_message)
152
- return
153
-
154
- # Step 6 — Post-process and display result
155
- post_processor = PostProcessor.create_processor()
156
- final_plan = post_processor.validate_and_clean(response.json_text)
157
-
158
- extracted_values = post_processor.extract_nutrition_values(final_plan)
159
- nutrition_targets.update(extracted_values)
160
-
161
- final_plan = final_plan.replace("–", "-").replace("—", "-").replace("→", "->")
162
-
163
- print(final_plan)
164
-
165
- # Option to save as JSON
166
- save_option = input("\nDo you want to save this plan? (yes/no): ").strip().lower()
167
- if save_option == "yes":
168
- plan_data = {
169
- "pet_profile": pet_profile.model_dump(),
170
- "nutrition_targets": nutrition_targets,
171
- "plan": {
172
- "text_lines": [line.strip() for line in final_plan.splitlines() if line.strip()],
173
- "sections": post_processor.parse_plan_sections(final_plan)
174
- }
175
- }
176
-
177
- with open("nutrition_plan.json", "w", encoding="utf-8") as f:
178
- json.dump(plan_data, f, indent=2, ensure_ascii=False)
179
- print("Plan saved as nutrition_plan.json")
180
-
181
- if __name__ == "__main__":
182
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nutrition_engine.py CHANGED
@@ -1,55 +1,361 @@
1
  """
2
- Deterministic nutrition calculation engine.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  """
4
  import logging
5
- from typing import Dict, Any
6
- import math
7
 
8
  from config import settings
9
- from schema import PetProfile, PetType, LifeStage, NeuteredSpayed, NutritionTargets
 
 
 
 
 
 
 
 
 
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
- class NutritionEngine:
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  @staticmethod
16
  def create_engine() -> "NutritionEngine":
17
- return NutritionEngine()
18
 
 
 
19
  def calculate_nutrition_targets(self, profile: PetProfile) -> Dict[str, Any]:
20
- rer = 70 * (profile.weight_kg ** 0.75)
21
-
22
- pet_type_key = profile.pet_type.value.lower()
23
- multipliers = settings.MER_MULTIPLIERS.get(pet_type_key, {})
24
-
25
- if profile.pet_type == PetType.DOG:
26
- if profile.life_stage == LifeStage.PUPPY:
27
- multiplier_key = "puppy_under_4m"
28
- elif profile.neutered_spayed == NeuteredSpayed.YES:
29
- multiplier_key = "adult_neutered"
30
- else:
31
- multiplier_key = "adult_intact"
32
- else:
33
- if profile.life_stage == LifeStage.KITTEN:
34
- multiplier_key = "kitten"
35
- elif profile.neutered_spayed == NeuteredSpayed.YES:
36
- multiplier_key = "adult_neutered"
37
- else:
38
- multiplier_key = "adult_intact"
39
-
40
- multiplier = multipliers.get(multiplier_key, 1.0)
 
 
 
 
 
 
 
 
 
41
  mer = rer * multiplier
42
- water_ml = profile.weight_kg * 25
43
-
44
- targets = {
45
- "rer": round(rer, 2),
46
- "mer": round(mer, 2),
47
- "daily_calorie_target": round(mer, 2),
48
- "protein_g": 0,
49
- "fat_g": 0,
50
- "carbohydrates_g": 0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  "water_ml": round(water_ml, 2),
 
52
  }
53
-
54
- logger.info(f"Calculated nutrition targets: RER={targets['rer']}, MER={targets['mer']}")
55
- return targets
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Deterministic nutrition calculation engine based on authoritative sources.
3
+
4
+ All calculations use NRC/AAFCO/WSAVA guidelines with full source attribution.
5
+
6
+ Key Principles:
7
+ 1. RER/MER are calculated but kept internal (in audit trail, not user response)
8
+ 2. Macronutrients calculated deterministically
9
+ 3. All calculations cite authoritative sources
10
+ 4. Weight validation to catch implausible inputs
11
+
12
+ Sources:
13
+ - NRC 2006: Nutrient Requirements of Dogs and Cats
14
+ - AAFCO 2024: Dog and Cat Food Nutrient Profiles
15
+ - WSAVA: Global Nutrition Guidelines
16
+ - FEDIAF: European Pet Food Guidelines
17
  """
18
  import logging
19
+ from typing import Dict, Any, Optional, Tuple
20
+ from datetime import datetime
21
 
22
  from config import settings
23
+ from schema import (
24
+ PetProfile,
25
+ PetType,
26
+ LifeStage,
27
+ NeuteredSpayed,
28
+ PregnantLactating,
29
+ ActivityLevel,
30
+ NutritionSource,
31
+ CalculationAudit
32
+ )
33
 
34
  logger = logging.getLogger(__name__)
35
 
 
36
 
37
+ class NutritionEngine:
38
+
39
+ def __init__(self):
40
+ self.energy_densities = settings.ENERGY_DENSITIES
41
+ self.digestibility_factor = settings.DIGESTIBILITY_FACTOR
42
+
43
+ # Source citations
44
+ self.nrc_source = NutritionSource(
45
+ source_type="NRC",
46
+ citation="National Research Council. 2006. Nutrient Requirements of Dogs and Cats. Washington, DC: The National Academies Press."
47
+ )
48
+
49
+ self.aafco_source = NutritionSource(
50
+ source_type="AAFCO",
51
+ citation="AAFCO 2024 Official Publication - Dog and Cat Food Nutrient Profiles"
52
+ )
53
+
54
  @staticmethod
55
  def create_engine() -> "NutritionEngine":
 
56
 
57
+ return NutritionEngine()
58
+
59
  def calculate_nutrition_targets(self, profile: PetProfile) -> Dict[str, Any]:
60
+ """
61
+ Calculate complete nutrition targets with audit trail.
62
+
63
+ Process:
64
+ 1. Validate weight plausibility
65
+ 2. Calculate RER (Resting Energy Requirement)
66
+ 3. Select MER multiplier from authoritative table
67
+ 4. Calculate MER (Maintenance Energy Requirement)
68
+ 5. Calculate macronutrients (% → calories → grams)
69
+ 6. Calculate water requirement
70
+ 7. Create audit trail
71
+
72
+ Args:
73
+ profile: Validated pet profile
74
+
75
+ Returns:
76
+ {
77
+ "daily_calorie_target": float,
78
+ "protein_g": float,
79
+ "fat_g": float,
80
+ "carbohydrates_g": float,
81
+ "water_ml": float,
82
+ "_audit": CalculationAudit (internal only)
83
+ }
84
+
85
+ Note: RER and MER are in _audit, NOT in user-facing response
86
+ """
87
+ self._validate_weight(profile)
88
+ rer = self._calculate_rer(profile.weight_kg)
89
+ multiplier, multiplier_key = self._select_mer_multiplier(profile)
90
  mer = rer * multiplier
91
+ daily_calorie_target = mer
92
+ macros = self._calculate_macronutrients(daily_calorie_target, profile)
93
+ water_ml = self._calculate_water(daily_calorie_target, profile)
94
+ audit = CalculationAudit(
95
+ rer=round(rer, 2),
96
+ mer=round(mer, 2),
97
+ mer_multiplier=multiplier,
98
+ mer_source=self.nrc_source,
99
+ protein_pct_source=self.aafco_source,
100
+ fat_pct_source=self.aafco_source,
101
+ timestamp=datetime.utcnow().isoformat()
102
+ )
103
+
104
+ logger.info(
105
+ f"Nutrition calculated for {profile.pet_type.value} ({profile.weight_kg}kg): "
106
+ f"RER={rer:.2f} kcal/day, MER={mer:.2f} kcal/day (multiplier={multiplier}, key={multiplier_key})"
107
+ )
108
+
109
+ logger.info(
110
+ f"Macros: protein={macros['protein_g']:.2f}g, fat={macros['fat_g']:.2f}g, "
111
+ f"carbs={macros['carbohydrates_g']:.2f}g, water={water_ml:.2f}ml"
112
+ )
113
+
114
+ return {
115
+ "daily_calorie_target": round(daily_calorie_target, 2),
116
+ "protein_g": round(macros["protein_g"], 2),
117
+ "fat_g": round(macros["fat_g"], 2),
118
+ "carbohydrates_g": round(macros["carbohydrates_g"], 2),
119
  "water_ml": round(water_ml, 2),
120
+ "_audit": audit,
121
  }
122
+
123
+ def _validate_weight(self, profile: PetProfile):
124
+ ranges = settings.WEIGHT_RANGES.get(profile.pet_type.value, {})
125
+
126
+ if profile.pet_type == PetType.CAT:
127
+ min_w, max_w = ranges.get("all", (2.0, 12.0))
128
+ if not (min_w <= profile.weight_kg <= max_w):
129
+ raise ValueError(
130
+ f"Implausible weight for cat: {profile.weight_kg}kg. "
131
+ f"Typical domestic cats: {min_w}-{max_w}kg. "
132
+ f"Please verify this is correct."
133
+ )
134
+
135
+ elif profile.pet_type == PetType.DOG:
136
+ all_ranges = list(ranges.values())
137
+ if all_ranges:
138
+ min_w = min(r[0] for r in all_ranges)
139
+ max_w = max(r[1] for r in all_ranges)
140
+ if not (min_w <= profile.weight_kg <= max_w):
141
+ raise ValueError(
142
+ f"Implausible weight for dog: {profile.weight_kg}kg. "
143
+ f"Typical dogs: {min_w}-{max_w}kg. "
144
+ f"Please verify this is correct."
145
+ )
146
+
147
+ logger.debug(f"Weight validation passed: {profile.weight_kg}kg for {profile.pet_type.value}")
148
+
149
+ def _calculate_rer(self, weight_kg: float) -> float:
150
+ """
151
+ Calculate Resting Energy Requirement using NRC formula.
152
+
153
+ Formula: RER = 70 × (weight_kg ** 0.75)
154
+
155
+ Source: NRC 2006 - Nutrient Requirements of Dogs and Cats
156
+
157
+ Args:
158
+ weight_kg: Body weight in kilograms
159
+
160
+ Returns:
161
+ RER in kcal/day
162
+ """
163
+ rer = 70 * (weight_kg ** 0.75)
164
+ logger.debug(f"RER calculated: {rer:.2f} kcal/day for {weight_kg}kg")
165
+ return rer
166
+
167
+ def _select_mer_multiplier(self, profile: PetProfile) -> Tuple[float, str]:
168
+ """
169
+ Select MER multiplier based on life stage, neuter status, activity, BCS.
170
+
171
+ Uses authoritative multiplier tables from MSD Vet Manual.
172
+
173
+ Args:
174
+ profile: Pet profile
175
+
176
+ Returns:
177
+ (multiplier, multiplier_key) - multiplier value and lookup key used
178
+ """
179
+ pet_type = profile.pet_type.value
180
+ multipliers = settings.ENERGY_MULTIPLIERS.get(pet_type, {})
181
+
182
+ key = self._build_multiplier_key(profile)
183
+
184
+ multiplier = multipliers.get(key, 1.6)
185
+
186
+ logger.debug(f"MER multiplier selected: {multiplier} (key: {key})")
187
+
188
+ return multiplier, key
189
+
190
+ def _build_multiplier_key(self, profile: PetProfile) -> str:
191
+
192
+ if profile.pregnant_lactating == PregnantLactating.LACTATING:
193
+ return "lactating_peak"
194
+ if profile.pregnant_lactating == PregnantLactating.PREGNANT:
195
+ return "pregnant"
196
+
197
+ if profile.life_stage == LifeStage.PUPPY:
198
+ return "puppy_over_4m"
199
+ if profile.life_stage == LifeStage.KITTEN:
200
+ return "kitten"
201
+
202
+ if profile.life_stage == LifeStage.SENIOR:
203
+
204
+ if profile.activity_level == ActivityLevel.LOW:
205
+ return "senior_sedentary"
206
+ else:
207
+ return "senior_active"
208
+
209
+ if profile.neutered_spayed == NeuteredSpayed.YES:
210
+ if profile.bcs > 6:
211
+ return "adult_obesity_prone"
212
+ return "adult_neutered"
213
+ else:
214
+ if profile.bcs > 6:
215
+ return "adult_obesity_prone"
216
+ return "adult_intact"
217
+
218
+ def _calculate_macronutrients(
219
+ self,
220
+ daily_calorie_target: float,
221
+ profile: PetProfile
222
+ ) -> Dict[str, Any]:
223
+ """
224
+ Calculate protein, fat, carbs in grams using % targets.
225
+
226
+ Process (as specified in requirements):
227
+ 1. Select % of calories targets from AAFCO/NRC tables
228
+ 2. Convert to calories using daily_calorie_target
229
+ 3. Convert to grams using energy densities (4/9/4 kcal/g)
230
+ 4. Apply digestibility factor
231
+ 5. Validate against minimums
232
+
233
+ Args:
234
+ daily_calorie_target: Total daily calories (MER)
235
+ profile: Pet profile
236
+
237
+ Returns:
238
+ {
239
+ "protein_g": float,
240
+ "fat_g": float,
241
+ "carbohydrates_g": float,
242
+ }
243
+ """
244
+ pet_type = profile.pet_type.value
245
+
246
+ life_stage_key = self._get_macro_life_stage_key(profile)
247
+
248
+ macro_table = settings.MACRONUTRIENT_TARGETS.get(pet_type, {}).get(
249
+ life_stage_key, {}
250
+ )
251
+
252
+ if not macro_table:
253
+ logger.warning(f"No macro table for {pet_type}/{life_stage_key}, using adult_maintenance")
254
+ macro_table = settings.MACRONUTRIENT_TARGETS.get(pet_type, {}).get("adult_maintenance", {})
255
+
256
+ protein_pct = macro_table.get("protein_pct", 20.0)
257
+ fat_pct = macro_table.get("fat_pct", 25.0)
258
+
259
+ protein_pct, fat_pct = self._adjust_macros_for_conditions(protein_pct, fat_pct, profile)
260
+
261
+ protein_calories = daily_calorie_target * (protein_pct / 100)
262
+ fat_calories = daily_calorie_target * (fat_pct / 100)
263
+ carb_calories = daily_calorie_target - protein_calories - fat_calories
264
+
265
+ protein_g = (protein_calories / self.energy_densities["protein"]) * self.digestibility_factor
266
+ fat_g = (fat_calories / self.energy_densities["fat"]) * self.digestibility_factor
267
+ carbohydrates_g = carb_calories / self.energy_densities["carbohydrate"]
268
+
269
+ min_protein_pct = macro_table.get("min_protein_pct", 18.0)
270
+ if protein_pct < min_protein_pct:
271
+ logger.warning(f"Protein {protein_pct}% below minimum {min_protein_pct}%, adjusting")
272
+ protein_pct = min_protein_pct
273
+ protein_calories = daily_calorie_target * (protein_pct / 100)
274
+ protein_g = (protein_calories / self.energy_densities["protein"]) * self.digestibility_factor
275
+
276
+ logger.debug(
277
+ f"Macros calculated: protein={protein_pct}%→{protein_g:.2f}g, "
278
+ f"fat={fat_pct}%→{fat_g:.2f}g, carbs→{carbohydrates_g:.2f}g"
279
+ )
280
+
281
+ return {
282
+ "protein_g": protein_g,
283
+ "fat_g": fat_g,
284
+ "carbohydrates_g": carbohydrates_g,
285
+ }
286
+
287
+ def _get_macro_life_stage_key(self, profile: PetProfile) -> str:
288
+ if profile.life_stage == LifeStage.SENIOR:
289
+ return "senior"
290
+ elif profile.life_stage in [LifeStage.PUPPY, LifeStage.KITTEN]:
291
+ return "puppy_growth" if profile.pet_type == PetType.DOG else "kitten_growth"
292
+ else:
293
+ return "adult_maintenance"
294
+
295
+ def _adjust_macros_for_conditions(
296
+ self,
297
+ protein_pct: float,
298
+ fat_pct: float,
299
+ profile: PetProfile
300
+ ) -> Tuple[float, float]:
301
+
302
+
303
+ # Pregnancy: increase protein by 25%
304
+ if profile.pregnant_lactating == PregnantLactating.PREGNANT:
305
+ protein_pct *= 1.25
306
+ logger.debug(f"Pregnancy adjustment: protein increased 25% to {protein_pct:.1f}%")
307
+
308
+ # Lactation: increase protein by 50%
309
+ if profile.pregnant_lactating == PregnantLactating.LACTATING:
310
+ protein_pct *= 1.5
311
+ logger.debug(f"Lactation adjustment: protein increased 50% to {protein_pct:.1f}%")
312
+
313
+
314
+ return protein_pct, fat_pct
315
+
316
+ def _calculate_water(self, daily_calorie_target: float, profile: PetProfile) -> float:
317
+ """
318
+ Calculate water requirement.
319
+
320
+ Base rule: 1 ml water per kcal consumed daily
321
+ Adjustments for lactation, growth, activity, senior
322
+
323
+ Source: General veterinary guidelines (WSAVA, Merck Vet Manual)
324
+
325
+ Args:
326
+ daily_calorie_target: MER in kcal/day
327
+ profile: Pet profile
328
+
329
+ Returns:
330
+ Water requirement in ml/day
331
+ """
332
+ # Base calculation
333
+ water_ml = daily_calorie_target * settings.WATER_ML_PER_KCAL
334
+
335
+ # Apply multipliers for special conditions
336
+ multiplier = 1.0
337
+ multiplier_reason = "base"
338
+
339
+ if profile.pregnant_lactating == PregnantLactating.LACTATING:
340
+ multiplier = settings.WATER_MULTIPLIERS.get("lactating", 2.0)
341
+ multiplier_reason = "lactating"
342
+ elif profile.pregnant_lactating == PregnantLactating.PREGNANT:
343
+ multiplier = settings.WATER_MULTIPLIERS.get("pregnant", 1.5)
344
+ multiplier_reason = "pregnant"
345
+ elif profile.life_stage in [LifeStage.PUPPY, LifeStage.KITTEN]:
346
+ key = "puppy" if profile.pet_type == PetType.DOG else "kitten"
347
+ multiplier = settings.WATER_MULTIPLIERS.get(key, 1.5)
348
+ multiplier_reason = "growth"
349
+ elif profile.life_stage == LifeStage.SENIOR:
350
+ multiplier = settings.WATER_MULTIPLIERS.get("senior", 1.2)
351
+ multiplier_reason = "senior"
352
+ elif profile.activity_level == ActivityLevel.HIGH:
353
+ multiplier = settings.WATER_MULTIPLIERS.get("high_activity", 1.5)
354
+ multiplier_reason = "high_activity"
355
+
356
+ water_ml *= multiplier
357
+
358
+ if multiplier > 1.0:
359
+ logger.debug(f"Water adjusted: {multiplier}x for {multiplier_reason} → {water_ml:.2f}ml")
360
+
361
+ return water_ml
nutrition_plan.json DELETED
@@ -1,38 +0,0 @@
1
- {
2
- "pet_profile": {
3
- "pet_type": "cat",
4
- "breed": "siamese cat",
5
- "gender": "female",
6
- "pregnant_lactating": "pregnant",
7
- "weight_kg": 35.0,
8
- "activity_level": "low",
9
- "life_stage": "adult",
10
- "bcs": 7,
11
- "neutered_spayed": "no",
12
- "allergies": "none",
13
- "diseases": "none",
14
- "diet_preference": "mixed",
15
- "current_food_format": "dry"
16
- },
17
- "nutrition_targets": {
18
- "rer": 1007.28,
19
- "mer": 1410.19,
20
- "daily_calorie_target": 1410.19,
21
- "protein_g": 153.1,
22
- "fat_g": 46.5,
23
- "carbohydrates_g": 137.2,
24
- "water_ml": 875.0
25
- },
26
- "plan": {
27
- "text": "PET NUTRITION PLAN -\n\nDAILY NUTRITION TARGETS\n\nEnergy Requirements\nRER: 1007.28 kcal/day\nMER: 1410.19 kcal/day\nDaily Calorie Target: 1410.19 kcal/day\n\nMacronutrients (Approx.)\nProtein: 153.1 g/day\nFat: 46.5 g/day\nCarbohydrates: 137.2 g/day\n\nHydration\nWater Intake: 900-1000 ml/day (inclusive of food moisture)\n\nRECOMMENDED MEAL PLAN WITH FEEDING SCHEDULE\nNumber of Meals: 3\nMeal Distribution: 33%-33%-34%\n\n8:00 AM - Morning Protein Bowl\nType: Home-Cooked / Wet Commercial\nCalories: 465 kcal\nIngredients: 250g cooked boneless, skinless chicken breast, 20g cooked chicken liver, 20g steamed green beans, 1/2 teaspoon salmon oil, veterinarian-prescribed complete and balanced vitamin/mineral supplement for home-cooked diets.\nPreparation: Cook chicken breast and liver thoroughly without seasoning. Steam green beans until tender. Dice all solid ingredients finely or mash. Mix thoroughly with salmon oil and the prescribed supplement.\nCommercial Alternative: 1.5 cans (e.g., 5.5 oz / 156g cans) of a high-protein, moderate-fat canned cat food (e.g., Wellness CORE Tuna & Salmon or Weruva Paw Lickin' Chicken) with approximately 300 kcal per can, ensuring the total calories match the target.\n\n1:00 PM - Mid-day Balanced Meal\nType: Home-Cooked / Wet Commercial\nCalories: 475 kcal\nIngredients: 200g cooked salmon fillet (skinless, boneless), 40g cooked mashed sweet potato, 1/2 teaspoon olive oil, veterinarian-prescribed complete and balanced vitamin/mineral supplement for home-cooked diets.\nPreparation: Bake or steam salmon until thoroughly cooked and flaked. Boil or steam sweet potato until soft, then mash. Mix salmon, mashed sweet potato, olive oil, and the prescribed supplement thoroughly.\nCommercial Alternative: 1.5 cans (e.g., 5.5 oz / 156g cans) of a different flavor of high-protein, moderate-fat canned cat food (e.g., Merrick Purrfect Bistro Beef Pate or Tiki Cat After Dark Chicken & Lamb), ensuring the total calories match the target.\n\n7:00 PM - Evening Nutritious Feast\nType: Home-Cooked / Dry Commercial\nCalories: 470 kcal\nIngredients: 180g cooked lean ground beef (90% lean or higher), 20g plain pumpkin puree, 1/4 cooked egg yolk (mashed), 1/4 teaspoon salmon oil, veterinarian-prescribed complete and balanced vitamin/mineral supplement for home-cooked diets.\nPreparation: Brown ground beef thoroughly, drain all fat. Mix with plain pumpkin puree, mashed cooked egg yolk, salmon oil, and the prescribed supplement. Ensure no seasonings.\nCommercial Alternative: 120-130g (approximately 4.2-4.6 oz) of a high-quality, high-protein dry cat food (e.g., Orijen Cat & Kitten or Acana Wild Prairie) with approximately 400 kcal per 100g, ensuring the total calories match the target. This can be served with a small amount of warm water to create a gravy.\n\nFEEDING INSTRUCTIONS\n1. Measure all food ingredients or commercial portions precisely using a kitchen scale for accuracy, as the daily calorie target is high and portion control is critical.\n2. Always ensure fresh, clean water is available at all times. Consider multiple water bowls or a pet water fountain to encourage increased hydration.\n3. Monitor your cat's body condition regularly. Given the BCS of 7, the ultimate goal is safe and gradual weight loss, which will require subsequent calorie adjustments under veterinary supervision.\n4. Introduce any new food ingredients or commercial diets gradually over a period of 7-10 days, mixing increasing amounts of the new food with decreasing amounts of the old food to prevent digestive upset.\n5. All home-cooked meals MUST be formulated with a veterinary nutritionist-approved complete and balanced vitamin and mineral supplement designed for home-cooked cat diets to prevent nutritional deficiencies.\n\nFOOD CATEGORIES\nDangerous: Onions, garlic (all forms), chocolate, grapes, raisins, xylitol, avocado, alcohol, raw yeast dough.\nAvoid: Raw meat (due to bacterial contamination risks), excessive amounts of tuna (mercury concerns), large or cooked bones (choking hazard, splintering), dairy products (many cats are lactose intolerant), caffeinated beverages.\nSafe: Cooked lean meats (chicken, turkey, beef, lamb), cooked fish (salmon, cod, tilapia, ensuring no bones), cooked eggs, plain pumpkin puree, cooked green beans, cooked carrots, cooked sweet potato (in moderation), small amounts of cooked whole grains (e.g., plain oats, brown rice) in balanced diets.\n\nSAFETY NOTES\n1. The provided daily calorie target of 1410.19 kcal/day is exceptionally high for a cat and typically corresponds to a much larger animal. Immediate veterinary consultation is crucial to verify this target, investigate the listed 35kg weight, and develop a safe and effective weight management plan for a cat with a BCS of 7 (obese).\n2. Home-cooked diets, especially for specific calorie targets and conditions, require precise formulation by a veterinary nutritionist to ensure they are nutritionally complete and balanced. The provided home-cooked recipes are examples based on the calorie target but must be completed with a veterinary-approved supplement.\n3. Closely monitor your cat for any adverse reactions to new foods, including vomiting, diarrhea, lethargy, or changes in appetite. Contact your veterinarian if any concerns arise.\n4. Ensure all food is served at an appropriate temperature (room temperature) and remove any uneaten food after 30-60 minutes to prevent spoilage and bacterial growth, especially with wet or home-cooked meals.\n5. Regular veterinary check-ups are essential to monitor your cat's overall health, track weight loss progress, and make any necessary adjustments to the diet plan.\n\nDISCLAIMER\nThis nutrition plan is AI-generated guidance and not a medical prescription. Consult a veterinarian for medical conditions or major diet changes.",
28
- "sections": {
29
- "header": "PET NUTRITION PLAN -",
30
- "nutrition_targets": "DAILY NUTRITION TARGETS\n\nEnergy Requirements\nRER: 1007.28 kcal/day\nMER: 1410.19 kcal/day\nDaily Calorie Target: 1410.19 kcal/day\n\nMacronutrients (Approx.)\nProtein: 153.1 g/day\nFat: 46.5 g/day\nCarbohydrates: 137.2 g/day\n\nHydration\nWater Intake: 900-1000 ml/day (inclusive of food moisture)",
31
- "meal_plan": "RECOMMENDED MEAL PLAN WITH FEEDING SCHEDULE\nNumber of Meals: 3\nMeal Distribution: 33%-33%-34%\n\n8:00 AM - Morning Protein Bowl\nType: Home-Cooked / Wet Commercial\nCalories: 465 kcal\nIngredients: 250g cooked boneless, skinless chicken breast, 20g cooked chicken liver, 20g steamed green beans, 1/2 teaspoon salmon oil, veterinarian-prescribed complete and balanced vitamin/mineral supplement for home-cooked diets.\nPreparation: Cook chicken breast and liver thoroughly without seasoning. Steam green beans until tender. Dice all solid ingredients finely or mash. Mix thoroughly with salmon oil and the prescribed supplement.\nCommercial Alternative: 1.5 cans (e.g., 5.5 oz / 156g cans) of a high-protein, moderate-fat canned cat food (e.g., Wellness CORE Tuna & Salmon or Weruva Paw Lickin' Chicken) with approximately 300 kcal per can, ensuring the total calories match the target.\n\n1:00 PM - Mid-day Balanced Meal\nType: Home-Cooked / Wet Commercial\nCalories: 475 kcal\nIngredients: 200g cooked salmon fillet (skinless, boneless), 40g cooked mashed sweet potato, 1/2 teaspoon olive oil, veterinarian-prescribed complete and balanced vitamin/mineral supplement for home-cooked diets.\nPreparation: Bake or steam salmon until thoroughly cooked and flaked. Boil or steam sweet potato until soft, then mash. Mix salmon, mashed sweet potato, olive oil, and the prescribed supplement thoroughly.\nCommercial Alternative: 1.5 cans (e.g., 5.5 oz / 156g cans) of a different flavor of high-protein, moderate-fat canned cat food (e.g., Merrick Purrfect Bistro Beef Pate or Tiki Cat After Dark Chicken & Lamb), ensuring the total calories match the target.\n\n7:00 PM - Evening Nutritious Feast\nType: Home-Cooked / Dry Commercial\nCalories: 470 kcal\nIngredients: 180g cooked lean ground beef (90% lean or higher), 20g plain pumpkin puree, 1/4 cooked egg yolk (mashed), 1/4 teaspoon salmon oil, veterinarian-prescribed complete and balanced vitamin/mineral supplement for home-cooked diets.\nPreparation: Brown ground beef thoroughly, drain all fat. Mix with plain pumpkin puree, mashed cooked egg yolk, salmon oil, and the prescribed supplement. Ensure no seasonings.\nCommercial Alternative: 120-130g (approximately 4.2-4.6 oz) of a high-quality, high-protein dry cat food (e.g., Orijen Cat & Kitten or Acana Wild Prairie) with approximately 400 kcal per 100g, ensuring the total calories match the target. This can be served with a small amount of warm water to create a gravy.",
32
- "feeding_instructions": "FEEDING INSTRUCTIONS\n1. Measure all food ingredients or commercial portions precisely using a kitchen scale for accuracy, as the daily calorie target is high and portion control is critical.\n2. Always ensure fresh, clean water is available at all times. Consider multiple water bowls or a pet water fountain to encourage increased hydration.\n3. Monitor your cat's body condition regularly. Given the BCS of 7, the ultimate goal is safe and gradual weight loss, which will require subsequent calorie adjustments under veterinary supervision.\n4. Introduce any new food ingredients or commercial diets gradually over a period of 7-10 days, mixing increasing amounts of the new food with decreasing amounts of the old food to prevent digestive upset.\n5. All home-cooked meals MUST be formulated with a veterinary nutritionist-approved complete and balanced vitamin and mineral supplement designed for home-cooked cat diets to prevent nutritional deficiencies.",
33
- "food_categories": "FOOD CATEGORIES\nDangerous: Onions, garlic (all forms), chocolate, grapes, raisins, xylitol, avocado, alcohol, raw yeast dough.\nAvoid: Raw meat (due to bacterial contamination risks), excessive amounts of tuna (mercury concerns), large or cooked bones (choking hazard, splintering), dairy products (many cats are lactose intolerant), caffeinated beverages.\nSafe: Cooked lean meats (chicken, turkey, beef, lamb), cooked fish (salmon, cod, tilapia, ensuring no bones), cooked eggs, plain pumpkin puree, cooked green beans, cooked carrots, cooked sweet potato (in moderation), small amounts of cooked whole grains (e.g., plain oats, brown rice) in balanced diets.",
34
- "safety_notes": "SAFETY NOTES\n1. The provided daily calorie target of 1410.19 kcal/day is exceptionally high for a cat and typically corresponds to a much larger animal. Immediate veterinary consultation is crucial to verify this target, investigate the listed 35kg weight, and develop a safe and effective weight management plan for a cat with a BCS of 7 (obese).\n2. Home-cooked diets, especially for specific calorie targets and conditions, require precise formulation by a veterinary nutritionist to ensure they are nutritionally complete and balanced. The provided home-cooked recipes are examples based on the calorie target but must be completed with a veterinary-approved supplement.\n3. Closely monitor your cat for any adverse reactions to new foods, including vomiting, diarrhea, lethargy, or changes in appetite. Contact your veterinarian if any concerns arise.\n4. Ensure all food is served at an appropriate temperature (room temperature) and remove any uneaten food after 30-60 minutes to prevent spoilage and bacterial growth, especially with wet or home-cooked meals.\n5. Regular veterinary check-ups are essential to monitor your cat's overall health, track weight loss progress, and make any necessary adjustments to the diet plan.",
35
- "disclaimer": "DISCLAIMER\nThis nutrition plan is AI-generated guidance and not a medical prescription. Consult a veterinarian for medical conditions or major diet changes."
36
- }
37
- }
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
postprocess.py CHANGED
@@ -63,48 +63,6 @@ class PostProcessor:
63
  pass
64
  return values
65
 
66
- # def extract_nutrition_values(self, final_plan: str) -> dict:
67
- # values = {}
68
- # lines = final_plan.split('\n')
69
- # for line in lines:
70
- # line = line.strip()
71
- # if line.startswith('RER:'):
72
- # try:
73
- # values['rer'] = float(line.split('kcal/day')[0].split('RER:')[1].strip())
74
- # except:
75
- # pass
76
- # elif line.startswith('MER:'):
77
- # try:
78
- # values['mer'] = float(line.split('kcal/day')[0].split('MER:')[1].strip())
79
- # except:
80
- # pass
81
- # elif line.startswith('Daily Calorie Target:'):
82
- # try:
83
- # values['daily_calorie_target'] = float(line.split('kcal/day')[0].split('Daily Calorie Target:')[1].strip())
84
- # except:
85
- # pass
86
- # elif line.startswith('Protein:'):
87
- # try:
88
- # values['protein_g'] = float(line.split('g/day')[0].split('Protein:')[1].strip())
89
- # except:
90
- # pass
91
- # elif line.startswith('Fat:'):
92
- # try:
93
- # values['fat_g'] = float(line.split('g/day')[0].split('Fat:')[1].strip())
94
- # except:
95
- # pass
96
- # elif line.startswith('Carbohydrates:'):
97
- # try:
98
- # values['carbohydrates_g'] = float(line.split('g/day')[0].split('Carbohydrates:')[1].strip())
99
- # except:
100
- # pass
101
- # elif line.startswith('Water Intake:'):
102
- # try:
103
- # values['water_ml'] = float(line.split('ml/day')[0].split('Water Intake:')[1].strip())
104
- # except:
105
- # pass
106
- # return values
107
-
108
  def parse_plan_sections(self, final_plan: str) -> dict:
109
  sections = {}
110
  lines = final_plan.split('\n')
@@ -163,11 +121,51 @@ class PostProcessor:
163
  return text
164
 
165
  def format_sections_for_api(self, sections: dict) -> dict:
166
- """
167
- Convert sections with \n into array of lines for better API response
168
- """
169
  formatted = {}
170
  for key, value in sections.items():
171
- # Split by \n and remove empty strings
172
- formatted[key] = [line for line in value.split('\n') if line.strip()]
 
 
173
  return formatted
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  pass
64
  return values
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def parse_plan_sections(self, final_plan: str) -> dict:
67
  sections = {}
68
  lines = final_plan.split('\n')
 
121
  return text
122
 
123
  def format_sections_for_api(self, sections: dict) -> dict:
 
 
 
124
  formatted = {}
125
  for key, value in sections.items():
126
+ if key == 'food_categories':
127
+ formatted[key] = self._parse_food_categories(value)
128
+ else:
129
+ formatted[key] = [line for line in value.split('\n') if line.strip()]
130
  return formatted
131
+
132
+ def _parse_food_categories(self, text: str) -> dict:
133
+
134
+ result = {
135
+ "dangerous": [],
136
+ "avoid": [],
137
+ "safe": []
138
+ }
139
+
140
+ lines = text.split('\n')
141
+ current_category = None
142
+
143
+ for line in lines:
144
+ line = line.strip()
145
+ if not line:
146
+ continue
147
+
148
+ if line.lower().startswith('dangerous:'):
149
+ current_category = 'dangerous'
150
+ items_text = line.split(':', 1)[1].strip()
151
+ if items_text:
152
+ items = [item.strip() for item in items_text.split(',') if item.strip()]
153
+ result['dangerous'].extend(items)
154
+ elif line.lower().startswith('avoid:'):
155
+ current_category = 'avoid'
156
+ items_text = line.split(':', 1)[1].strip()
157
+ if items_text:
158
+ items = [item.strip() for item in items_text.split(',') if item.strip()]
159
+ result['avoid'].extend(items)
160
+ elif line.lower().startswith('safe:'):
161
+ current_category = 'safe'
162
+ items_text = line.split(':', 1)[1].strip()
163
+ if items_text:
164
+ items = [item.strip() for item in items_text.split(',') if item.strip()]
165
+ result['safe'].extend(items)
166
+ else:
167
+ if current_category:
168
+ items = [item.strip() for item in line.split(',') if item.strip()]
169
+ result[current_category].extend(items)
170
+
171
+ return result
rag/embed.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Embedding layer for vectorization.
3
  """
4
  import logging
5
  from typing import List
 
1
  """
2
+ Embedding layer.
3
  """
4
  import logging
5
  from typing import List
rag/retriever.py CHANGED
@@ -12,10 +12,10 @@ from config import settings
12
  logger = logging.getLogger(__name__)
13
 
14
  class Retriever:
15
- def __init__(self, embedder: EmbeddingLayer, vectorstore: VectorStore):
16
  self.embedder = embedder
17
  self.vectorstore = vectorstore
18
- self.cross_encoder = CrossEncoder(settings.RERANK_MODEL)
19
 
20
  def retrieve(self, query: str, top_k: int = 20) -> List[Dict[str, Any]]:
21
  query_vec = self.embedder.embed_query(query)
@@ -61,14 +61,6 @@ class Retriever:
61
 
62
  class KnowledgeRetriever:
63
 
64
- @staticmethod
65
- def create() -> 'KnowledgeRetriever':
66
- embedder = EmbeddingLayer.create_embedder()
67
- vectorstore = VectorStore.create_vectorstore()
68
- vectorstore.load()
69
- retriever = Retriever(embedder, vectorstore)
70
- return KnowledgeRetriever(retriever)
71
-
72
  def __init__(self, retriever: Retriever):
73
  self.retriever = retriever
74
 
 
12
  logger = logging.getLogger(__name__)
13
 
14
  class Retriever:
15
+ def __init__(self, embedder: EmbeddingLayer, vectorstore: VectorStore, cross_encoder: CrossEncoder):
16
  self.embedder = embedder
17
  self.vectorstore = vectorstore
18
+ self.cross_encoder = cross_encoder
19
 
20
  def retrieve(self, query: str, top_k: int = 20) -> List[Dict[str, Any]]:
21
  query_vec = self.embedder.embed_query(query)
 
61
 
62
  class KnowledgeRetriever:
63
 
 
 
 
 
 
 
 
 
64
  def __init__(self, retriever: Retriever):
65
  self.retriever = retriever
66
 
requirements.txt CHANGED
@@ -1,23 +1,26 @@
 
1
  python-dotenv==1.0.0
2
  pydantic==2.5.0
3
  pydantic-settings==2.1.0
4
 
5
- langchain==0.1.0
6
- langchain-community==0.0.10
7
- langchain-google-genai==0.0.5
8
- langchain-core
9
 
 
10
  faiss-cpu
11
  sentence-transformers>=2.2.2
 
12
 
13
- torch
 
14
 
15
- numpy==1.26.2
 
 
16
  requests==2.31.0
17
- google-generativeai==0.3.2
18
- pymupdf4llm>=0.0.3
19
- tqdm==4.66.1
20
- tiktoken>=0.5.2
21
 
22
- fastapi==0.115.12
23
- uvicorn[standard]==0.34.2
 
 
1
+ # Core
2
  python-dotenv==1.0.0
3
  pydantic==2.5.0
4
  pydantic-settings==2.1.0
5
 
6
+ langchain==1.2.10
7
+ langchain-community==1.2.10
8
+ langchain-google-genai==2.0.5
9
+ langchain-core==1.2.10
10
 
11
+ # Vector Store & Embeddings
12
  faiss-cpu
13
  sentence-transformers>=2.2.2
14
+ torch
15
 
16
+ # PDF Processing
17
+ pymupdf>=1.23.0
18
 
19
+ # API
20
+ fastapi==0.115.12
21
+ uvicorn[standard]==0.34.2
22
  requests==2.31.0
 
 
 
 
23
 
24
+ # Utilities
25
+ tqdm==4.66.1
26
+ tiktoken>=0.5.2
router.py CHANGED
@@ -1,66 +1,160 @@
1
- from fastapi import APIRouter, HTTPException
2
- from schema import NutritionRequest, NutritionResponse
 
 
3
 
4
  from input_handler import InputHandler
5
  from nutrition_engine import NutritionEngine
6
  from rules_engine import RulesEngine
7
- from load_kb import KnowledgeBaseLoader
8
- from rag.retriever import KnowledgeRetriever
9
- from llm.gemini_client import GeminiClient
10
  from llm.prompt_templates import PromptBuilder
11
  from postprocess import PostProcessor
 
 
12
  from config import settings
13
 
14
  router = APIRouter(prefix="/nutrition", tags=["Pet Nutrition"])
 
15
 
 
 
 
 
 
 
 
16
 
17
  @router.post("/plan", response_model=NutritionResponse)
18
- def generate_plan(payload: NutritionRequest):
19
-
 
 
 
20
  input_handler = InputHandler.create_handler()
21
  pet_profile = input_handler.validate_and_parse(payload.model_dump())
22
 
23
  if not pet_profile:
24
  raise HTTPException(status_code=400, detail="Invalid input")
25
 
 
26
  nutrition_engine = NutritionEngine.create_engine()
27
- rules_engine = RulesEngine.create_engine()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- kb_loader = KnowledgeBaseLoader.create_loader()
30
- kb_loader.load_knowledge_base(force_rebuild=False)
31
-
32
- nutrition_targets = nutrition_engine.calculate_nutrition_targets(pet_profile)
33
  rules_engine.apply_rules(pet_profile, nutrition_targets)
34
 
35
- retriever = KnowledgeRetriever.create()
 
36
  prompt_builder = PromptBuilder()
37
-
38
  query = prompt_builder.build_retrieval_query(pet_profile, nutrition_targets)
39
- retrieved_chunks = retriever.retrieve(query, top_k=settings.RETRIEVAL_TOP_K)
40
-
41
- llm_client = GeminiClient.create()
42
- chain = prompt_builder.generation_template | llm_client.llm
43
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  response = chain.invoke({
45
  "pet_profile": pet_profile.model_dump(),
46
- "nutrition_targets": nutrition_targets,
 
 
 
 
47
  "retrieved_context": "\n\n".join(c["content"] for c in retrieved_chunks),
48
- "instructions": prompt_builder.instructions,
49
  })
 
50
 
 
51
  post_processor = PostProcessor.create_processor()
52
  final_plan = post_processor.validate_and_clean(response.content)
53
 
54
- extracted_values = post_processor.extract_nutrition_values(final_plan)
55
- nutrition_targets.update(extracted_values)
56
-
57
- clean_lines = [line.strip() for line in final_plan.splitlines() if line.strip()]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  return {
60
  "pet_profile": pet_profile.model_dump(),
61
- "nutrition_targets": nutrition_targets,
62
  "plan": {
63
- "text_lines": clean_lines,
64
- "sections": post_processor.format_sections_for_api(post_processor.parse_plan_sections(final_plan))
65
  }
66
  }
 
 
1
+ from fastapi import APIRouter, HTTPException, Request
2
+ import logging
3
+
4
+ from schema import NutritionRequest, NutritionResponse, DailyTargets
5
 
6
  from input_handler import InputHandler
7
  from nutrition_engine import NutritionEngine
8
  from rules_engine import RulesEngine
 
 
 
9
  from llm.prompt_templates import PromptBuilder
10
  from postprocess import PostProcessor
11
+ from validators.calorie_validator import CalorieDriftValidator
12
+ from validators.dynamic_food_categories import DynamicFoodCategoryBuilder
13
  from config import settings
14
 
15
  router = APIRouter(prefix="/nutrition", tags=["Pet Nutrition"])
16
+ logger = logging.getLogger(__name__)
17
 
18
+ SECTION_ORDER = [
19
+ "meal_plan",
20
+ "feeding_instructions",
21
+ "food_categories",
22
+ "safety_notes",
23
+ "disclaimer",
24
+ ]
25
 
26
  @router.post("/plan", response_model=NutritionResponse)
27
+ def generate_plan(request: Request, payload: NutritionRequest):
28
+
29
+ app_state = request.app.state.app_state
30
+
31
+ # Validate input
32
  input_handler = InputHandler.create_handler()
33
  pet_profile = input_handler.validate_and_parse(payload.model_dump())
34
 
35
  if not pet_profile:
36
  raise HTTPException(status_code=400, detail="Invalid input")
37
 
38
+ # Calculate nutrition targets (deterministic, lightweight)
39
  nutrition_engine = NutritionEngine.create_engine()
40
+
41
+ try:
42
+ nutrition_targets = nutrition_engine.calculate_nutrition_targets(pet_profile)
43
+ except ValueError as e:
44
+ raise HTTPException(status_code=400, detail=str(e))
45
+
46
+ audit = nutrition_targets.pop("_audit")
47
+
48
+ logger.info(
49
+ f"Nutrition calculated: RER={audit.rer} kcal/day, MER={audit.mer} kcal/day "
50
+ f"(multiplier={audit.mer_multiplier})"
51
+ )
52
+ logger.info(
53
+ f"Daily targets: {nutrition_targets['daily_calorie_target']} kcal, "
54
+ f"{nutrition_targets['protein_g']}g protein, {nutrition_targets['fat_g']}g fat"
55
+ )
56
 
57
+ rules_engine = RulesEngine.create_engine()
 
 
 
58
  rules_engine.apply_rules(pet_profile, nutrition_targets)
59
 
60
+ # USE SHARED STATE
61
+ # Step 1: Retrieve relevant context
62
  prompt_builder = PromptBuilder()
 
63
  query = prompt_builder.build_retrieval_query(pet_profile, nutrition_targets)
64
+
65
+ logger.debug(f"Retrieval query: {query[:100]}...")
66
+ retrieved_chunks = app_state.retriever.retrieve(query, top_k=settings.RETRIEVAL_TOP_K)
67
+ logger.info(f"Retrieved {len(retrieved_chunks)} chunks")
68
+
69
+ # Step 2: Build food categories (uses retrieved_chunks)
70
+ logger.info("Building food categories...")
71
+ food_builder = DynamicFoodCategoryBuilder.create_builder()
72
+ food_categories = food_builder.build_categories(
73
+ pet_profile.model_dump(),
74
+ retrieved_chunks
75
+ )
76
+ food_categories_text = (
77
+ f"Dangerous: {', '.join(food_categories.dangerous)}\n"
78
+ f"Avoid: {', '.join(food_categories.avoid)}\n"
79
+ f"Safe: {', '.join(food_categories.safe)}"
80
+ )
81
+ logger.info(f" Food categories built - {len(food_categories.dangerous)} dangerous, {len(food_categories.avoid)} avoid, {len(food_categories.safe)} safe")
82
+
83
+ # Step 3: Generate meal plan
84
+ chain = prompt_builder.generation_template | app_state.llm_client.llm
85
+
86
+ logger.info("Generating meal plan with Gemini...")
87
  response = chain.invoke({
88
  "pet_profile": pet_profile.model_dump(),
89
+ "daily_calorie_target": nutrition_targets["daily_calorie_target"],
90
+ "daily_protein_g": nutrition_targets["protein_g"],
91
+ "daily_fat_g": nutrition_targets["fat_g"],
92
+ "daily_carbohydrates_g": nutrition_targets["carbohydrates_g"],
93
+ "daily_water_ml": nutrition_targets["water_ml"],
94
  "retrieved_context": "\n\n".join(c["content"] for c in retrieved_chunks),
95
+ "food_categories": food_categories_text,
96
  })
97
+ logger.info("Meal plan generated")
98
 
99
+ # Step 4: Post-process the LLM response
100
  post_processor = PostProcessor.create_processor()
101
  final_plan = post_processor.validate_and_clean(response.content)
102
 
103
+ # Step 5: Parse sections
104
+ sections = post_processor.parse_plan_sections(final_plan)
105
+ formatted_sections = post_processor.format_sections_for_api(sections)
106
+
107
+ # Step 6: CALORIE DRIFT VALIDATION (±15% tolerance)
108
+ logger.info("Validating calorie drift...")
109
+ calorie_validator = CalorieDriftValidator()
110
+ calorie_validator.TOLERANCE = 0.15 # ±15%
111
+
112
+ # Extract meal plan text for validation
113
+ meal_text = "\\n".join(formatted_sections.get("meal_plan", []))
114
+ corrected_meal_text, validation_result = calorie_validator.validate_and_correct(
115
+ meal_text,
116
+ nutrition_targets["daily_calorie_target"]
117
+ )
118
+
119
+ if validation_result.get("corrected"):
120
+ logger.info(f"Calorie drift corrected: scaled by {validation_result['scaling_factor']}")
121
+ formatted_sections["meal_plan"] = corrected_meal_text.split("\\n")
122
+ elif validation_result["within_tolerance"]:
123
+ logger.info(f"Calorie validation passed: {validation_result['deviation_pct']}% deviation")
124
+ else:
125
+ logger.warning(f"Calorie validation: {validation_result.get('message', 'No data')}")
126
+
127
+ clean_sections = {}
128
+ for key in SECTION_ORDER:
129
+ section_data = formatted_sections.get(key, [])
130
+
131
+ if section_data and isinstance(section_data, list) and len(section_data) > 0:
132
+ first_line = section_data[0]
133
+ uppercase_labels = [
134
+ "DISCLAIMER", "SAFETY NOTES", "FEEDING INSTRUCTIONS",
135
+ "FOOD CATEGORIES", "RECOMMENDED MEAL PLAN WITH FEEDING SCHEDULE",
136
+ "MEAL PLAN", "PET NUTRITION PLAN", "DAILY NUTRITION TARGETS"
137
+ ]
138
+ if first_line.strip().upper() in uppercase_labels:
139
+ section_data = section_data[1:]
140
+
141
+ clean_sections[key] = section_data if section_data else []
142
+
143
+ logger.info(f"Sections ordered and cleaned: {list(clean_sections.keys())}")
144
+
145
+ daily_targets_dict = {
146
+ "daily_calorie_target": nutrition_targets["daily_calorie_target"],
147
+ "daily_protein_g": nutrition_targets["protein_g"],
148
+ "daily_fat_g": nutrition_targets["fat_g"],
149
+ "daily_carbohydrates_g": nutrition_targets["carbohydrates_g"],
150
+ "daily_water_ml": nutrition_targets["water_ml"],
151
+ }
152
 
153
  return {
154
  "pet_profile": pet_profile.model_dump(),
155
+ "nutrition_targets": daily_targets_dict,
156
  "plan": {
157
+ "sections": clean_sections
 
158
  }
159
  }
160
+
schema.py CHANGED
@@ -127,13 +127,108 @@ class NutritionRequest(BaseModel):
127
 
128
 
129
  class NutritionResponse(BaseModel):
130
- pet_profile: Dict[str, Any]
131
- nutrition_targets: Dict[str, Any]
132
- plan: dict
133
-
134
-
135
-
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
 
139
 
 
127
 
128
 
129
  class NutritionResponse(BaseModel):
 
 
 
 
 
 
130
 
131
+ pet_profile: Dict[str, Any]
132
+ nutrition_targets: Dict[str, float] # Plain dict - more flexible
133
+ plan: Dict[str, Any]
134
+
135
+ # USDA FoodData Central Schemas
136
+ class USDANutrient(BaseModel):
137
+ """Single nutrient from USDA FoodData Central."""
138
+ nutrient_id: int
139
+ nutrient_name: str
140
+ unit_name: str
141
+ value: float
142
+
143
+
144
+ class USDAFoodItem(BaseModel):
145
+ """USDA food item metadata."""
146
+ fdc_id: int
147
+ description: str
148
+ brand_owner: Optional[str] = None
149
+ ingredients: Optional[str] = None
150
+
151
+
152
+ class USDAFoodData(BaseModel):
153
+ """Complete USDA food data with nutrients."""
154
+ food_item: USDAFoodItem
155
+ nutrients: List[USDANutrient]
156
+ source: str = "USDA FoodData Central"
157
+
158
+ class NutritionSource(BaseModel):
159
+ """Track authoritative sources for calculations (NRC, AAFCO, WSAVA, etc)."""
160
+ source_type: str = Field(..., description="Source type: NRC, AAFCO, WSAVA, FEDIAF, USDA")
161
+ citation: str = Field(..., description="Full citation text")
162
+ url: Optional[str] = Field(None, description="Reference URL if available")
163
+ year: Optional[int] = Field(None, description="Publication year")
164
+
165
+
166
+ class CalculationAudit(BaseModel):
167
+ """
168
+ Audit trail for nutrition calculations.
169
+ Contains internal calculations (RER/MER) that are logged but not shown to users.
170
+ """
171
+ rer: float = Field(..., description="Resting Energy Requirement (kcal/day)")
172
+ mer: float = Field(..., description="Maintenance Energy Requirement (kcal/day)")
173
+ mer_multiplier: float = Field(..., description="Multiplier used to calculate MER")
174
+ mer_source: NutritionSource = Field(..., description="Source for MER multiplier")
175
+ protein_pct_source: NutritionSource = Field(..., description="Source for protein percentage target")
176
+ fat_pct_source: NutritionSource = Field(..., description="Source for fat percentage target")
177
+ timestamp: str = Field(..., description="ISO timestamp of calculation")
178
+
179
+ class DailyTargets(BaseModel):
180
+
181
+ daily_calorie_target: float = Field(..., description="Total calories per day (kcal)")
182
+ protein_g: float = Field(..., description="Protein in grams per day")
183
+ fat_g: float = Field(..., description="Fat in grams per day")
184
+ carbohydrates_g: float = Field(..., description="Carbohydrates in grams per day")
185
+ water_ml: float = Field(..., description="Water in milliliters per day")
186
+
187
+ # Structured Output Schemas
188
+ class MealOption(BaseModel):
189
+ """
190
+ Single meal option (Option A or B).
191
+
192
+ Used for structured LLM output parsing.
193
+ """
194
+ option_label: str = Field(..., description="Option A or Option B")
195
+ meal_type: str = Field(..., description="e.g., Breakfast, Lunch, Dinner")
196
+ calories: float = Field(..., description="Estimated calories for this meal")
197
+ ingredients: List[str] = Field(..., min_length=1, description="List of ingredients")
198
+ quantities: List[str] = Field(..., min_length=1, description="Quantities with units (e.g., '100g', '1 cup')")
199
+ preparation_steps: List[str] = Field(..., min_length=1, description="Step-by-step preparation instructions")
200
+ nutrition_notes: Optional[str] = Field(None, description="Optional nutrition highlights")
201
+
202
+
203
+ class Meal(BaseModel):
204
+
205
+ meal_time: str = Field(..., description="Time of day (e.g., 'Morning', 'Evening')")
206
+ meal_name: str = Field(..., description="Meal name (e.g., 'Breakfast', 'Dinner')")
207
+ options: List[MealOption] = Field(..., min_length=2, max_length=2, description="Option A and Option B")
208
+
209
+
210
+ class MealPlan(BaseModel):
211
+ """
212
+ Complete daily meal plan with multiple meals.
213
+ """
214
+ num_meals: int = Field(..., ge=2, le=4, description="Number of meals per day (typically 2-3)")
215
+ meals: List[Meal] = Field(..., min_length=2, description="List of all meals")
216
+
217
+
218
+ class FoodCategories(BaseModel):
219
+
220
+ dangerous: List[str] = Field(..., max_length=10, description="Toxic/dangerous foods to NEVER feed")
221
+ avoid: List[str] = Field(..., max_length=10, description="Foods to avoid (not toxic but unhealthy)")
222
+ safe: List[str] = Field(..., max_length=10, description="Safe and healthy foods")
223
+
224
+
225
+ class NutritionPlanOutput(BaseModel):
226
+
227
+ meal_plan: MealPlan = Field(..., description="Complete meal plan with options")
228
+ feeding_instructions: List[str] = Field(..., max_length=7, description="General feeding guidelines")
229
+ food_categories: FoodCategories = Field(..., description="Safe/dangerous food lists")
230
+ safety_notes: List[str] = Field(..., max_length=5, description="Important safety warnings")
231
+ hydration_tips: Optional[List[str]] = Field(None, max_length=3, description="Water intake recommendations")
232
 
233
 
234
 
scripts/rebuild_kb.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Script to rebuild the knowledge base from PDFs.
3
+
4
+ """
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).parent.parent))
10
+
11
+ from load_kb import KnowledgeBaseLoader
12
+ logging.basicConfig(
13
+ level=logging.INFO,
14
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
15
+ )
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def main():
20
+
21
+ print("KNOWLEDGE BASE REBUILD SCRIPT")
22
+ print()
23
+
24
+ try:
25
+ # Create loader
26
+ logger.info("Initializing knowledge base loader...")
27
+ kb_loader = KnowledgeBaseLoader.create_loader()
28
+
29
+ # Force rebuild
30
+ logger.info("Starting KB rebuild (force_rebuild=True)...")
31
+ print()
32
+
33
+ result = kb_loader.load_knowledge_base(force_rebuild=True)
34
+
35
+ # Display results
36
+ print()
37
+
38
+ print("KNOWLEDGE BASE REBUILD COMPLETE")
39
+
40
+ print(f"Status: {result['status']}")
41
+ print(f"PDFs: {result.get('pdfs', 'N/A')}")
42
+ print(f"Chunks: {result['chunks']}")
43
+ print(f"Time: {result['time_sec']}s")
44
+
45
+ print()
46
+
47
+ # Verification
48
+ if result['chunks'] > 3000:
49
+ print("Success! Expected ~3309 chunks, got", result['chunks'])
50
+ elif result['chunks'] > 2000:
51
+ print("Warning: Got", result['chunks'], "chunks (expected ~3309)")
52
+ else:
53
+ print("Error: Only", result['chunks'], "chunks (expected ~3309)")
54
+
55
+ print()
56
+ print("Vector store saved to: kb/vector_db/")
57
+ print()
58
+
59
+ except Exception as e:
60
+ logger.exception("Failed to rebuild knowledge base")
61
+ print()
62
+
63
+ print("ERROR")
64
+
65
+ print(f"Failed to rebuild KB: {e}")
66
+
67
+ sys.exit(1)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
services/ingredient_nutrition.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ingredient nutrition service with USDA fallback to LLM.
3
+ """
4
+ import logging
5
+ from typing import Dict, Any, Optional
6
+
7
+ from kb.usda_client import USDAClient
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class IngredientNutritionService:
13
+
14
+
15
+ def __init__(self, usda_client: Optional[USDAClient] = None, llm_client=None):
16
+
17
+ self.usda = usda_client or USDAClient.create()
18
+ self.llm = llm_client
19
+ logger.debug("IngredientNutritionService initialized")
20
+
21
+ @staticmethod
22
+ def create(usda_client=None, llm_client=None) -> "IngredientNutritionService":
23
+
24
+ return IngredientNutritionService(usda_client, llm_client)
25
+
26
+ def get_nutrition(
27
+ self,
28
+ ingredient: str,
29
+ quantity_grams: float = 100.0
30
+ ) -> Dict[str, Any]:
31
+
32
+ # Try USDA first
33
+ logger.debug(f"Looking up nutrition for '{ingredient}' ({quantity_grams}g)")
34
+
35
+ usda_nutrition = self.usda.get_nutrition_for_ingredient(ingredient, quantity_grams)
36
+
37
+ if usda_nutrition:
38
+ logger.info(f"USDA data found for '{ingredient}'")
39
+ return usda_nutrition
40
+
41
+ logger.warning(f"USDA failed for '{ingredient}', using LLM fallback")
42
+ return self._llm_estimate(ingredient, quantity_grams)
43
+
44
+ def _llm_estimate(self, ingredient: str, quantity_grams: float) -> Dict[str, Any]:
45
+
46
+ if not self.llm:
47
+ logger.error("No LLM available for fallback estimation")
48
+ return self._default_fallback(ingredient, quantity_grams)
49
+
50
+ try:
51
+
52
+ logger.warning(f"LLM estimation not yet implemented, using defaults for '{ingredient}'")
53
+ return self._default_fallback(ingredient, quantity_grams)
54
+
55
+ except Exception as e:
56
+ logger.error(f"LLM estimation failed: {e}")
57
+ return self._default_fallback(ingredient, quantity_grams)
58
+
59
+ def _default_fallback(self, ingredient: str, quantity_grams: float) -> Dict[str, Any]:
60
+
61
+ logger.error(f"All methods failed for '{ingredient}', returning zeros")
62
+
63
+ return {
64
+ "calories": 0.0,
65
+ "protein_g": 0.0,
66
+ "fat_g": 0.0,
67
+ "carbs_g": 0.0,
68
+ "source": "UNKNOWN",
69
+ "description": ingredient,
70
+ "warning": "Nutrition data unavailable - both USDA and LLM failed"
71
+ }
validators/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .calorie_validator import CalorieDriftValidator
2
+ from .dynamic_food_categories import DynamicFoodCategoryBuilder, FoodCategories
3
+
4
+ __all__ = ["CalorieDriftValidator", "DynamicFood CategoryBuilder", "FoodCategories"]
validators/calorie_validator.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import re
3
+ from typing import Dict, List, Tuple, Optional
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ class CalorieDriftValidator:
9
+
10
+ TOLERANCE = 0.05 # ±5% tolerance
11
+
12
+ @staticmethod
13
+ def create_validator() -> 'CalorieDriftValidator':
14
+ return CalorieDriftValidator()
15
+
16
+ def extract_meal_calories(self, meal_plan_text: str) -> List[Tuple[str, float]]:
17
+
18
+ meals = []
19
+
20
+ pattern = r'([\w\s]+)\s*\(Approx\.\s*(\d+(?:\.\d+)?)\s*kcal\)'
21
+ matches = re.findall(pattern, meal_plan_text, re.IGNORECASE)
22
+
23
+ for meal_name, cal_value in matches:
24
+ try:
25
+ calories = float(cal_value)
26
+ meals.append((meal_name.strip(), calories))
27
+ except ValueError:
28
+ logger.warning(f"Could not parse calorie value: {cal_value}")
29
+ continue
30
+
31
+ logger.info(f"Extracted {len(meals)} meals with calorie values")
32
+ return meals
33
+
34
+ def validate_total_calories(
35
+ self,
36
+ meal_calories: List[Tuple[str, float]],
37
+ daily_target: float
38
+ ) -> Dict[str, any]:
39
+
40
+ if not meal_calories:
41
+ return {
42
+ "valid": False,
43
+ "total_extracted": 0.0,
44
+ "target": daily_target,
45
+ "deviation_pct": 100.0,
46
+ "within_tolerance": False,
47
+ "message": "No meal calories extracted"
48
+ }
49
+
50
+ total_extracted = sum(cal for _, cal in meal_calories)
51
+ deviation = abs(total_extracted - daily_target) / daily_target
52
+ deviation_pct = deviation * 100
53
+
54
+ within_tolerance = deviation <= self.TOLERANCE
55
+
56
+ result = {
57
+ "valid": True,
58
+ "total_extracted": total_extracted,
59
+ "target": daily_target,
60
+ "deviation_pct": round(deviation_pct, 2),
61
+ "within_tolerance": within_tolerance
62
+ }
63
+
64
+ if within_tolerance:
65
+ logger.info(
66
+ f"Calorie validation PASSED: {total_extracted} kcal "
67
+ f"vs target {daily_target} kcal (deviation: {deviation_pct:.2f}%)"
68
+ )
69
+ else:
70
+ logger.warning(
71
+ f"Calorie validation FAILED: {total_extracted} kcal "
72
+ f"vs target {daily_target} kcal (deviation: {deviation_pct:.2f}% > {self.TOLERANCE*100}%)"
73
+ )
74
+
75
+ return result
76
+
77
+ def scale_meal_calories(
78
+ self,
79
+ meal_plan_text: str,
80
+ scaling_factor: float
81
+ ) -> str:
82
+
83
+ def replace_calorie(match):
84
+ meal_name = match.group(1)
85
+ original_cal = float(match.group(2))
86
+ scaled_cal = round(original_cal * scaling_factor, 1)
87
+ return f"{meal_name} (Approx. {int(scaled_cal)} kcal)"
88
+
89
+ pattern = r'([\w\s]+)\s*\(Approx\.\s*(\d+(?:\.\d+)?)\s*kcal\)'
90
+ scaled_text = re.sub(pattern, replace_calorie, meal_plan_text, flags=re.IGNORECASE)
91
+
92
+ logger.info(f"Scaled meal calories by factor {scaling_factor:.3f}")
93
+ return scaled_text
94
+
95
+ def validate_and_correct(
96
+ self,
97
+ meal_plan_text: str,
98
+ daily_calorie_target: float
99
+ ) -> Tuple[str, Dict[str, any]]:
100
+
101
+ # Extract meals
102
+ meal_calories = self.extract_meal_calories(meal_plan_text)
103
+
104
+ # Validate
105
+ validation = self.validate_total_calories(meal_calories, daily_calorie_target)
106
+
107
+ # If within tolerance, return as-is
108
+ if validation["within_tolerance"] or not validation["valid"]:
109
+ return meal_plan_text, validation
110
+
111
+ # Calculate scaling factor
112
+ total_extracted = validation["total_extracted"]
113
+ scaling_factor = daily_calorie_target / total_extracted
114
+
115
+ logger.info(
116
+ f"Applying calorie correction: scaling by {scaling_factor:.3f} "
117
+ f"to bring {total_extracted} → {daily_calorie_target} kcal"
118
+ )
119
+
120
+ # Scale calories
121
+ corrected_plan = self.scale_meal_calories(meal_plan_text, scaling_factor)
122
+
123
+ # Re-validate
124
+ corrected_meals = self.extract_meal_calories(corrected_plan)
125
+ final_validation = self.validate_total_calories(corrected_meals, daily_calorie_target)
126
+ final_validation["corrected"] = True
127
+ final_validation["scaling_factor"] = round(scaling_factor, 3)
128
+
129
+ return corrected_plan, final_validation
validators/dynamic_food_categories.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Dict, List, Set
3
+ from dataclasses import dataclass
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ @dataclass
9
+ class FoodCategories:
10
+ """Pet-specific food safety categories."""
11
+ dangerous: List[str]
12
+ avoid: List[str]
13
+ safe: List[str]
14
+
15
+
16
+ class DynamicFoodCategoryBuilder:
17
+ """Builds dynamic food categories based on pet profile."""
18
+
19
+ DOG_DANGEROUS = [
20
+ "chocolate", "xylitol", "grapes", "raisins", "onions", "garlic",
21
+ "macadamia nuts", "avocado", "alcohol", "caffeine"
22
+ ]
23
+
24
+ CAT_DANGEROUS = [
25
+ "onions", "garlic", "grapes", "raisins", "alcohol", "caffeine",
26
+ "chocolate", "xylitol", "raw dough", "chives"
27
+ ]
28
+
29
+ PUPPY_AVOID = ["adult dog food", "large bones", "high-fat treats"]
30
+ SENIOR_DOG_AVOID = ["high sodium foods", "overly rich foods"]
31
+
32
+ KITTEN_AVOID = ["adult cat food", "dog food", "raw eggs"]
33
+ SENIOR_CAT_AVOID = ["high phosphorus foods", "excessive protein"]
34
+
35
+ DISEASE_CONTRAINDICATIONS = {
36
+ "kidney": ["high phosphorus", "excessive protein", "high sodium"],
37
+ "liver": ["high copper foods", "high fat"],
38
+ "diabetes": ["high sugar", "simple carbs", "honey"],
39
+ "pancreatitis": ["high fat", "fatty meats", "fried foods"],
40
+ "heart": ["high sodium", "salt", "processed meats"],
41
+ "allergies": [],
42
+ }
43
+
44
+ @staticmethod
45
+ def create_builder() -> 'DynamicFoodCategoryBuilder':
46
+ return DynamicFoodCategoryBuilder()
47
+
48
+ def build_categories(
49
+ self,
50
+ pet_profile: Dict,
51
+ retrieved_chunks: List[Dict] = None
52
+ ) -> FoodCategories:
53
+
54
+ pet_type = pet_profile.get("pet_type", "dog").lower()
55
+ life_stage = pet_profile.get("life_stage", "adult").lower()
56
+ allergies = pet_profile.get("allergies") or ""
57
+ diseases = pet_profile.get("diseases") or ""
58
+
59
+ dangerous = set(self.DOG_DANGEROUS if pet_type == "dog" else self.CAT_DANGEROUS)
60
+ avoid = set()
61
+
62
+ if pet_type == "dog":
63
+ if life_stage == "puppy":
64
+ avoid.update(self.PUPPY_AVOID)
65
+ elif life_stage == "senior":
66
+ avoid.update(self.SENIOR_DOG_AVOID)
67
+ else: # cat
68
+ if life_stage == "kitten":
69
+ avoid.update(self.KITTEN_AVOID)
70
+ elif life_stage == "senior":
71
+ avoid.update(self.SENIOR_CAT_AVOID)
72
+
73
+ if allergies:
74
+ allergy_list = [a.strip().lower() for a in allergies.split(",")]
75
+ avoid.update(allergy_list)
76
+ logger.info(f"Added allergies to avoid list: {allergy_list}")
77
+
78
+ if diseases:
79
+ disease_list = [d.strip().lower() for d in diseases.split(",")]
80
+ for disease in disease_list:
81
+ for key, contraindications in self.DISEASE_CONTRAINDICATIONS.items():
82
+ if key in disease:
83
+ avoid.update(contraindications)
84
+ logger.info(f"Added {key} contraindications: {contraindications}")
85
+
86
+ if retrieved_chunks:
87
+ rag_contraindications = self._extract_contraindications_from_rag(
88
+ retrieved_chunks, diseases
89
+ )
90
+ if rag_contraindications:
91
+ avoid.update(rag_contraindications)
92
+ logger.info(f"Added RAG contraindications: {rag_contraindications}")
93
+
94
+ safe = self._get_safe_foods(pet_type, life_stage)
95
+
96
+ return FoodCategories(
97
+ dangerous=sorted(list(dangerous)),
98
+ avoid=sorted(list(avoid)),
99
+ safe=safe
100
+ )
101
+
102
+ def _extract_contraindications_from_rag(
103
+ self,
104
+ chunks: List[Dict],
105
+ diseases: str
106
+ ) -> Set[str]:
107
+ contraindications = set()
108
+
109
+ keywords = ["avoid", "contraindicated", "not recommended", "toxic for"]
110
+
111
+ for chunk in chunks:
112
+ content = chunk.get("content", "").lower()
113
+
114
+ for keyword in keywords:
115
+ if keyword in content and (diseases.lower() in content):
116
+ words = content.split()
117
+ if keyword in words:
118
+ idx = words.index(keyword)
119
+ potential = words[idx+1:idx+5]
120
+ for word in potential:
121
+ if len(word) > 3 and word.isalpha():
122
+ contraindications.add(word)
123
+
124
+ return contraindications
125
+
126
+ def _get_safe_foods(self, pet_type: str, life_stage: str) -> List[str]:
127
+ """Get general safe foods for this pet."""
128
+ if pet_type == "dog":
129
+ safe = [
130
+ "lean chicken", "turkey", "beef", "lamb",
131
+ "sweet potatoes", "carrots", "green beans", "peas",
132
+ "brown rice", "oatmeal", "pumpkin", "blueberries",
133
+ "plain yogurt", "eggs (cooked)"
134
+ ]
135
+ else:
136
+ safe = [
137
+ "cooked chicken", "turkey", "salmon", "tuna (moderation)",
138
+ "cooked eggs", "cooked fish", "plain chicken broth",
139
+ "small amounts of cooked vegetables"
140
+ ]
141
+
142
+ if life_stage == "puppy" or life_stage == "kitten":
143
+ safe.append("high-quality puppy/kitten formula food")
144
+ elif life_stage == "senior":
145
+ safe.append("easily digestible proteins")
146
+ safe.append("joint-supporting supplements")
147
+
148
+ return safe