Kashish commited on
Commit ·
345991e
1
Parent(s): 3ae6e65
Initial commit
Browse files- .gitattributes +4 -3
- .gitignore +25 -6
- README.md +38 -14
- app.py +108 -4
- chunker.py +0 -165
- config.py +101 -5
- kb/document_processor.py +143 -0
- kb/processed_docs/650285654-Nutrient-Requirements-of-Dogs-and-Cats_raw_extraction.json +0 -0
- kb/processed_docs/chunk_previews/650285654-Nutrient-Requirements-of-Dogs-and-Cats_chunks.txt +0 -0
- kb/usda_client.py +142 -56
- kb/vector_db/vector_index.faiss +2 -2
- kb/vector_db/vector_index_metadata.json +0 -0
- llm/chains.py +65 -0
- llm/output_parsers.py +83 -0
- llm/prompt_templates.py +102 -47
- load_kb.py +90 -40
- main.py +0 -182
- nutrition_engine.py +345 -39
- nutrition_plan.json +0 -38
- postprocess.py +45 -47
- rag/embed.py +1 -1
- rag/retriever.py +2 -10
- requirements.txt +15 -12
- router.py +122 -28
- schema.py +101 -6
- scripts/rebuild_kb.py +71 -0
- services/ingredient_nutrition.py +71 -0
- validators/__init__.py +4 -0
- validators/calorie_validator.py +129 -0
- validators/dynamic_food_categories.py +148 -0
.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 |
-
*.
|
| 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 |
-
|
| 2 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
|
|
|
|
|
|
|
|
|
| 4 |
*.log
|
| 5 |
nutrition_planner.log
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
--
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 6 |
-
version="
|
| 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 |
-
|
| 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
|
| 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
|
| 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 =
|
| 25 |
-
self.
|
| 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 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def get_food_details(self, fdc_id: int) -> Optional[USDAFoodData]:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
unit_name=n["nutrient"]["unitName"],
|
| 67 |
-
value=n.get("amount", 0.0),
|
| 68 |
-
)
|
| 69 |
)
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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
|
| 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
|
| 33 |
|
| 34 |
-
8.
|
| 35 |
|
| 36 |
-
|
| 37 |
|
| 38 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 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
|
| 62 |
-
Preparation: [
|
| 63 |
|
| 64 |
Option B
|
| 65 |
Type: ________
|
| 66 |
Calories: __________
|
| 67 |
-
Ingredients: [list actual ingredients
|
| 68 |
-
Preparation: [
|
| 69 |
|
| 70 |
[Repeat for each meal slot]
|
| 71 |
|
| 72 |
-
|
| 73 |
-
[numbered pointers with actual instructions, max
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
Avoid: [list]
|
| 78 |
-
Safe: [list]
|
| 79 |
|
| 80 |
-
|
| 81 |
[numbered pointers, MAX 5]
|
| 82 |
|
| 83 |
-
|
| 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 |
-
|
| 87 |
-
|
| 88 |
-
10. Use real, specific, actionable content only. Adapt dynamically based on retrieved knowledge and reasoning.
|
| 89 |
|
| 90 |
-
11.
|
| 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", "
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import logging
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import Dict, List
|
| 7 |
-
import numpy as np
|
| 8 |
import time
|
| 9 |
|
| 10 |
from config import settings
|
| 11 |
-
from kb.
|
| 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
|
| 37 |
return {
|
| 38 |
"status": "loaded",
|
| 39 |
"chunks": len(self.vector_store.records),
|
| 40 |
"time_sec": round(time.time() - start, 2),
|
| 41 |
}
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
if not pdf_files:
|
| 49 |
-
raise ValueError(f"No
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
for pdf in pdf_files:
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
embeddings = self.embedder.embed_texts(texts)
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
self.vector_store.add(embeddings, texts, metadatas)
|
| 64 |
self.vector_store.persist()
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
return {
|
| 69 |
"status": "rebuilt",
|
| 70 |
"pdfs": len(pdf_files),
|
| 71 |
-
"chunks": len(
|
| 72 |
-
"
|
| 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
|
| 7 |
|
| 8 |
from config import settings
|
| 9 |
-
from schema import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
mer = rer * multiplier
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
"water_ml": round(water_ml, 2),
|
|
|
|
| 52 |
}
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 172 |
-
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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==
|
| 6 |
-
langchain-community==
|
| 7 |
-
langchain-google-genai==
|
| 8 |
-
langchain-core
|
| 9 |
|
|
|
|
| 10 |
faiss-cpu
|
| 11 |
sentence-transformers>=2.2.2
|
|
|
|
| 12 |
|
| 13 |
-
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 23 |
-
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 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 |
-
|
|
|
|
| 36 |
prompt_builder = PromptBuilder()
|
| 37 |
-
|
| 38 |
query = prompt_builder.build_retrieval_query(pet_profile, nutrition_targets)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
response = chain.invoke({
|
| 45 |
"pet_profile": pet_profile.model_dump(),
|
| 46 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
"retrieved_context": "\n\n".join(c["content"] for c in retrieved_chunks),
|
| 48 |
-
"
|
| 49 |
})
|
|
|
|
| 50 |
|
|
|
|
| 51 |
post_processor = PostProcessor.create_processor()
|
| 52 |
final_plan = post_processor.validate_and_clean(response.content)
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
return {
|
| 60 |
"pet_profile": pet_profile.model_dump(),
|
| 61 |
-
"nutrition_targets":
|
| 62 |
"plan": {
|
| 63 |
-
"
|
| 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
|