muhammadnoman76 commited on
Commit
540cf5a
Β·
1 Parent(s): e02b28a
.gitignore CHANGED
@@ -1,14 +1,12 @@
1
- # Flask specific
2
- instance/*
3
- !instance/.gitignore
4
- .webassets-cache
5
- .env
6
-
7
- # Python related
8
  __pycache__/
9
  *.py[cod]
10
  *$py.class
 
 
11
  *.so
 
 
12
  .Python
13
  build/
14
  develop-eggs/
@@ -22,23 +20,140 @@ parts/
22
  sdist/
23
  var/
24
  wheels/
 
25
  *.egg-info/
26
  .installed.cfg
27
  *.egg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- # Virtual Environment
30
- .venv/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  venv/
32
  ENV/
 
 
33
 
34
- # IDE specific (common ones)
35
- .idea/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  .vscode/
 
37
  *.swp
38
  *.swo
 
39
  .DS_Store
40
 
41
- # Local development
42
- *.log
43
- .env.local
44
- .env.*.local
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
 
 
 
 
 
 
2
  __pycache__/
3
  *.py[cod]
4
  *$py.class
5
+
6
+ # C extensions
7
  *.so
8
+
9
+ # Distribution / packaging
10
  .Python
11
  build/
12
  develop-eggs/
 
20
  sdist/
21
  var/
22
  wheels/
23
+ share/python-wheels/
24
  *.egg-info/
25
  .installed.cfg
26
  *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ cover/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django stuff:
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+ db.sqlite3-journal
61
+
62
+ # Flask stuff:
63
+ instance/
64
+ .webassets-cache
65
+
66
+ # Scrapy stuff:
67
+ .scrapy
68
+
69
+ # Sphinx documentation
70
+ docs/_build/
71
+
72
+ # PyBuilder
73
+ .pybuilder/
74
+ target/
75
+
76
+ # Jupyter Notebook
77
+ .ipynb_checkpoints
78
+
79
+ # IPython
80
+ profile_default/
81
+ ipython_config.py
82
+
83
+ # pyenv
84
+ .python-version
85
 
86
+ # pipenv
87
+ Pipfile.lock
88
+
89
+ # poetry
90
+ poetry.lock
91
+
92
+ # pdm
93
+ .pdm.toml
94
+
95
+ # PEP 582
96
+ __pypackages__/
97
+
98
+ # Celery stuff
99
+ celerybeat-schedule
100
+ celerybeat.pid
101
+
102
+ # SageMath parsed files
103
+ *.sage.py
104
+
105
+ # Environments
106
+ .env
107
+ .venv
108
+ env/
109
  venv/
110
  ENV/
111
+ env.bak/
112
+ venv.bak/
113
 
114
+ # Spyder project settings
115
+ .spyderproject
116
+ .spyproject
117
+
118
+ # Rope project settings
119
+ .ropeproject
120
+
121
+ # mkdocs documentation
122
+ /site
123
+
124
+ # mypy
125
+ .mypy_cache/
126
+ .dmypy.json
127
+ dmypy.json
128
+
129
+ # Pyre type checker
130
+ .pyre/
131
+
132
+ # pytype static type analyzer
133
+ .pytype/
134
+
135
+ # Cython debug symbols
136
+ cython_debug/
137
+
138
+ # IDEs
139
  .vscode/
140
+ .idea/
141
  *.swp
142
  *.swo
143
+ *~
144
  .DS_Store
145
 
146
+ # Project specific
147
+ temp/
148
+ uploads/
149
+ *.pth
150
+ *.pkl
151
+ *.h5
152
+ *.model
153
+ logs/
154
+ backups/
155
+ nltk_data/
156
+
157
+ # Keep .gitkeep files
158
+ !.gitkeep
159
+ !app/config/temp/.gitkeep
CODE_DOCUMENTATION.md ADDED
The diff for this file is too large to render. See raw diff
 
Makefile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Makefile for Windows (PowerShell or Git Bash)
2
+
3
+ VENV_DIR = .venv
4
+ PYTHON = python
5
+
6
+ .PHONY: venv install run clean clean-all
7
+
8
+ # Create virtual environment
9
+ venv:
10
+ $(PYTHON) -m venv $(VENV_DIR)
11
+
12
+ # Install dependencies from pyproject.toml
13
+ install: venv
14
+ $(VENV_DIR)/Scripts/python -m pip install --upgrade pip setuptools wheel
15
+ $(VENV_DIR)/Scripts/pip install -e .
16
+
17
+ # Run the app
18
+ run:
19
+ $(VENV_DIR)/Scripts/python app.py
20
+
21
+ # Remove virtual environment
22
+ clean:
23
+ if exist $(VENV_DIR) rmdir /s /q $(VENV_DIR)
24
+
25
+ # Clean caches (__pycache__, .pytest_cache, *.pyc, *.pyo, build artifacts) - preserve .venv
26
+ clean-all:
27
+ - powershell -Command "Get-ChildItem -Recurse -Force -Include __pycache__,*.pyc,*.pyo,.pytest_cache,build,dist | Remove-Item -Recurse -Force"
app/main.py CHANGED
@@ -6,7 +6,7 @@ import os
6
  from dotenv import load_dotenv
7
  from app.config.config import Config
8
  from app.routers import admin, auth, chat, location, preferences, profile, questionnaire, language, chat_session
9
-
10
  load_dotenv()
11
 
12
  app = FastAPI(title="Skin AI API")
@@ -34,6 +34,8 @@ app.include_router(profile.router, prefix="/api", tags=["profile"])
34
  app.include_router(questionnaire.router, prefix="/api", tags=["questionnaire"])
35
  app.include_router(language.router, prefix="/api", tags=["language"])
36
  app.include_router(chat_session.router, prefix="/api", tags=["chat_session"])
 
 
37
 
38
  @app.get("/")
39
  async def root():
 
6
  from dotenv import load_dotenv
7
  from app.config.config import Config
8
  from app.routers import admin, auth, chat, location, preferences, profile, questionnaire, language, chat_session
9
+ from app.routers import agent_chat
10
  load_dotenv()
11
 
12
  app = FastAPI(title="Skin AI API")
 
34
  app.include_router(questionnaire.router, prefix="/api", tags=["questionnaire"])
35
  app.include_router(language.router, prefix="/api", tags=["language"])
36
  app.include_router(chat_session.router, prefix="/api", tags=["chat_session"])
37
+ app.include_router(agent_chat.router, prefix="/api", tags=["agent_chat"])
38
+
39
 
40
  @app.get("/")
41
  async def root():
app/routers/agent_chat.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ο»Ώfrom typing import Optional
2
+ import asyncio
3
+ import json
4
+ import logging
5
+
6
+ from fastapi import APIRouter, Depends, Header, HTTPException
7
+ from fastapi.responses import StreamingResponse
8
+ from pydantic import BaseModel
9
+
10
+ from app.middleware.auth import get_current_user
11
+ from app.services.google_agent_service import (
12
+ DEFAULT_MODEL_NAME,
13
+ GoogleAgentService,
14
+ )
15
+
16
+ router = APIRouter()
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class AgentChatRequest(BaseModel):
21
+ session_id: Optional[str] = None
22
+ query: str
23
+
24
+
25
+ async def stream_agent_response(agent_service: GoogleAgentService, query: str):
26
+ try:
27
+ async for event in agent_service.process_message_async(query):
28
+ event_type = event.get("type")
29
+
30
+ if event_type == "chunk":
31
+ payload = {"type": "chunk", "content": event.get("content", "")}
32
+ elif event_type == "tool_call":
33
+ payload = {
34
+ "type": "tool_call",
35
+ "tool_name": event.get("tool_name"),
36
+ "arguments": event.get("arguments", {}),
37
+ }
38
+ elif event_type == "tool_result":
39
+ payload = {
40
+ "type": "tool_result",
41
+ "tool_name": event.get("tool_name"),
42
+ "result": event.get("result", {}),
43
+ }
44
+ elif event_type == "completed":
45
+ payload = {
46
+ "type": "completed",
47
+ "saved": event.get("saved"),
48
+ "session_id": event.get("session_id"),
49
+ "response": event.get("response", ""),
50
+ "keywords": event.get("keywords", []),
51
+ "references": event.get("references", []),
52
+ "images": event.get("images", []),
53
+ }
54
+ elif event_type == "error":
55
+ payload = {"type": "error", "message": event.get("content", "")}
56
+ else:
57
+ payload = {"type": event_type or "unknown", "data": event}
58
+
59
+ yield f"data: {json.dumps(payload)}\n\n"
60
+ await asyncio.sleep(0.001)
61
+
62
+ yield "data: {\"type\": \"done\"}\n\n"
63
+ except Exception as exc:
64
+ logger.error("Streaming error: %s", exc, exc_info=True)
65
+ yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
66
+
67
+
68
+ @router.post("/agent-chat")
69
+ async def agent_chat(
70
+ request: AgentChatRequest,
71
+ authorization: str = Header(None),
72
+ username: str = Depends(get_current_user),
73
+ ):
74
+ if not authorization or not authorization.startswith("Bearer "):
75
+ raise HTTPException(status_code=401, detail="Invalid authorization header")
76
+
77
+ token = authorization.split(" ", 1)[1]
78
+
79
+ try:
80
+ agent_service = GoogleAgentService(token=token, session_id=request.session_id)
81
+ except Exception as exc:
82
+ logger.error("Failed to initialise agent service: %s", exc, exc_info=True)
83
+ raise HTTPException(status_code=500, detail="Unable to initialise agent")
84
+
85
+ return StreamingResponse(
86
+ stream_agent_response(agent_service, request.query),
87
+ media_type="text/event-stream",
88
+ headers={
89
+ "Cache-Control": "no-cache",
90
+ "Connection": "keep-alive",
91
+ "Content-Type": "text/event-stream",
92
+ "X-Accel-Buffering": "no",
93
+ "Access-Control-Allow-Origin": "*",
94
+ "Access-Control-Allow-Headers": "*",
95
+ },
96
+ )
97
+
98
+
99
+ @router.get("/agent-status")
100
+ async def agent_status(username: str = Depends(get_current_user)):
101
+ try:
102
+ return {
103
+ "status": "available",
104
+ "model": DEFAULT_MODEL_NAME,
105
+ "features": [
106
+ "web_search",
107
+ "vector_search",
108
+ "image_search",
109
+ "streaming",
110
+ "tool_calls",
111
+ ],
112
+ }
113
+ except Exception as exc:
114
+ logger.error("Agent status error: %s", exc, exc_info=True)
115
+ return {"status": "error", "message": str(exc)}
app/services/agent_service.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # import asyncio
2
+ # import os
3
+ # import sys
4
+ # from typing import Dict, Any, Optional, AsyncGenerator
5
+ # from datetime import datetime, timezone
6
+ # from google.adk.agents import Agent
7
+ # from google.adk.runners import InMemoryRunner
8
+ # from app.services.tools import get_web_search, get_vector_search, get_image_search
9
+ # from app.services.chathistory import ChatSession
10
+ # from app.services.environmental_condition import EnvironmentalData
11
+ # from app.services.agentic_prompt import get_web_search_prompt, get_vector_search_prompt
12
+ # from app.database.database_query import DatabaseQuery
13
+ # import logging
14
+ # import json
15
+
16
+ # if sys.platform.startswith('win'):
17
+ # asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
18
+
19
+ # # Set up logging
20
+ # logging.basicConfig(
21
+ # level=logging.INFO,
22
+ # format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
23
+ # )
24
+ # logger = logging.getLogger(__name__)
25
+
26
+ # # Set Google API key
27
+ # GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
28
+ # os.environ['GOOGLE_API_KEY'] = GOOGLE_API_KEY
29
+
30
+ # class GoogleAgentService:
31
+ # def __init__(self, token: str, session_id: Optional[str] = None):
32
+ # logger.info(f"Initializing GoogleAgentService with session: {session_id}")
33
+ # self.token = token
34
+ # self.session_id = session_id
35
+ # self.chat_session = ChatSession(token, session_id)
36
+ # self.query_db = DatabaseQuery()
37
+ # self.user_profile = self._get_user_profile()
38
+ # self.user_preferences = self._get_user_preferences()
39
+ # self.user_city = self.chat_session.get_city()
40
+ # self.environment_data = self._get_environmental_data()
41
+ # self.language = self.chat_session.get_language()
42
+ # self.agent = None
43
+ # self.runner = None
44
+ # self.agent_session = None
45
+ # self.tool_calls = []
46
+ # self.images_found = []
47
+
48
+ # logger.info(f"User preferences: {self.user_preferences}")
49
+ # logger.info(f"User city: {self.user_city}")
50
+ # logger.info(f"Language: {self.language}")
51
+
52
+ # # Initialize the appropriate agent
53
+ # self._initialize_agent()
54
+
55
+ # def _get_user_profile(self) -> Dict[str, Any]:
56
+ # """Get user profile information"""
57
+ # try:
58
+ # profile = self.chat_session.get_name_and_age()
59
+ # logger.info(f"Retrieved user profile: {profile}")
60
+ # return {
61
+ # 'name': profile.get('name', 'Patient'),
62
+ # 'age': profile.get('age', 'Unknown')
63
+ # }
64
+ # except Exception as e:
65
+ # logger.error(f"Error getting user profile: {e}")
66
+ # return {'name': 'Patient', 'age': 'Unknown'}
67
+
68
+ # def _get_user_preferences(self) -> Dict[str, Any]:
69
+ # """Get user preferences from database"""
70
+ # try:
71
+ # preferences = self.chat_session.get_user_preferences()
72
+ # logger.info(f"Retrieved user preferences: {preferences}")
73
+ # return preferences
74
+ # except Exception as e:
75
+ # logger.error(f"Error getting user preferences: {e}")
76
+ # return {
77
+ # 'websearch': False,
78
+ # 'keywords': True,
79
+ # 'references': True,
80
+ # 'environmental_recommendations': False,
81
+ # 'personalized_recommendations': False
82
+ # }
83
+
84
+ # def _get_environmental_data(self) -> str:
85
+ # """Get environmental data if user has provided location"""
86
+ # try:
87
+ # if self.user_city:
88
+ # env_data = EnvironmentalData(self.user_city)
89
+ # data = str(env_data.get_environmental_data())
90
+ # logger.info(f"Retrieved environmental data for {self.user_city}: {data[:100]}...")
91
+ # return data
92
+ # logger.info("No user city provided, skipping environmental data")
93
+ # return ""
94
+ # except Exception as e:
95
+ # logger.error(f"Error getting environmental data: {e}")
96
+ # return ""
97
+
98
+ # def _get_personalized_data(self) -> str:
99
+ # """Get personalized recommendations data"""
100
+ # try:
101
+ # data = self.chat_session.get_personalized_recommendation() or ""
102
+ # if data:
103
+ # logger.info(f"Retrieved personalized data: {data[:100]}...")
104
+ # return data
105
+ # except Exception as e:
106
+ # logger.error(f"Error getting personalized data: {e}")
107
+ # return ""
108
+
109
+ # def _prepare_user_data(self) -> Dict[str, Any]:
110
+ # """Prepare all user data for prompt generation"""
111
+ # user_data = {
112
+ # 'name': self.user_profile.get('name'),
113
+ # 'age': self.user_profile.get('age'),
114
+ # 'language': self.language,
115
+ # 'personalized_recommendations': self.user_preferences.get('personalized_recommendations'),
116
+ # 'environmental_recommendations': self.user_preferences.get('environmental_recommendations'),
117
+ # 'personalized_data': self._get_personalized_data() if self.user_preferences.get('personalized_recommendations') else "",
118
+ # 'environmental_data': self.environment_data if self.user_preferences.get('environmental_recommendations') else ""
119
+ # }
120
+ # logger.info(f"Prepared user data: {user_data}")
121
+ # return user_data
122
+
123
+ # def _initialize_agent(self):
124
+ # """Initialize the appropriate agent based on user preferences"""
125
+ # user_data = self._prepare_user_data()
126
+
127
+ # # Pass functions directly as tools - NO FunctionTool wrapper needed!
128
+ # if self.user_preferences.get('websearch', False):
129
+ # # Create web search agent
130
+ # logger.info("Initializing web search agent with tools")
131
+
132
+ # self.agent = Agent(
133
+ # name="web_search_agent",
134
+ # model="gemini-2.0-flash-exp",
135
+ # description="Expert dermatologist assistant using web search",
136
+ # instruction=get_web_search_prompt(user_data),
137
+ # tools=[get_web_search, get_image_search], # Pass functions directly!
138
+ # )
139
+ # logger.info(f"Web search agent initialized with tools: get_web_search, get_image_search")
140
+ # else:
141
+ # # Create vector search agent
142
+ # logger.info("Initializing vector search agent with tools")
143
+
144
+ # self.agent = Agent(
145
+ # name="vector_search_agent",
146
+ # model="gemini-2.0-flash-exp",
147
+ # description="Expert dermatologist assistant using medical knowledge base",
148
+ # instruction=get_vector_search_prompt(user_data),
149
+ # tools=[get_vector_search, get_image_search], # Pass functions directly!
150
+ # )
151
+ # logger.info(f"Vector search agent initialized with tools: get_vector_search, get_image_search")
152
+
153
+ # # Initialize runner with the agent object (not string!)
154
+ # self.runner = InMemoryRunner(
155
+ # agent=self.agent, # Pass the agent object
156
+ # app_name='dermai_chat_app',
157
+ # )
158
+ # logger.info(f"Agent and runner initialized successfully")
159
+
160
+ # async def create_session(self) -> Optional[Any]:
161
+ # """Create a new agent session"""
162
+ # try:
163
+ # logger.info(f"Creating new agent session for user: {self.chat_session.identity}")
164
+ # session = await self.runner.session_service.create_session(
165
+ # app_name='dermai_chat_app',
166
+ # user_id=self.chat_session.identity
167
+ # )
168
+ # self.agent_session = session
169
+ # logger.info(f"Agent session created with ID: {session.id}")
170
+ # return session
171
+ # except Exception as e:
172
+ # logger.error(f"Error creating session: {e}", exc_info=True)
173
+ # return None
174
+
175
+ # def _create_message_content(self, text: str) -> Dict[str, Any]:
176
+ # """Create a message content dictionary compatible with the runner"""
177
+ # # Create a simple content structure that the runner can understand
178
+ # return {
179
+ # 'role': 'user',
180
+ # 'parts': [{'text': text}]
181
+ # }
182
+
183
+ # async def process_message_async(self, query: str) -> AsyncGenerator[Dict[str, Any], None]:
184
+ # """Process message and yield streaming responses"""
185
+ # try:
186
+ # logger.info(f"Processing message: {query[:100]}...")
187
+
188
+ # # Reset tracking variables
189
+ # self.tool_calls = []
190
+ # self.images_found = []
191
+
192
+ # # Ensure session is created
193
+ # if not self.agent_session:
194
+ # logger.info("Creating new agent session...")
195
+ # await self.create_session()
196
+
197
+ # if not self.agent_session:
198
+ # logger.error("Failed to create agent session")
199
+ # yield {
200
+ # "type": "error",
201
+ # "content": "Failed to create agent session"
202
+ # }
203
+ # return
204
+
205
+ # # Create message content as a simple string
206
+ # # The runner will handle the conversion internally
207
+
208
+ # # Track response for final processing
209
+ # full_response = ""
210
+ # current_text = ""
211
+
212
+ # logger.info("Starting agent execution...")
213
+ # logger.info(f"Agent tools: {[tool.__name__ for tool in self.agent.tools] if self.agent.tools else 'No tools'}")
214
+
215
+ # # Use run_async for streaming
216
+ # try:
217
+ # events = self.runner.run_async(
218
+ # user_id=self.chat_session.identity,
219
+ # session_id=self.agent_session.id,
220
+ # new_message=query, # Pass query directly as string
221
+ # )
222
+
223
+ # async for event in events:
224
+ # # Debug logging
225
+ # logger.debug(f"Event type: {type(event).__name__}")
226
+
227
+ # # Handle text responses with streaming
228
+ # if hasattr(event, 'content') and event.content:
229
+ # if hasattr(event.content, 'parts') and event.content.parts:
230
+ # for part in event.content.parts:
231
+ # # Handle text parts
232
+ # if hasattr(part, 'text') and part.text:
233
+ # text_content = part.text
234
+
235
+ # # Check if this is a partial/streaming response
236
+ # if hasattr(event, 'partial') and event.partial:
237
+ # # Stream only the new chunk
238
+ # new_chunk = text_content[len(current_text):]
239
+ # if new_chunk:
240
+ # logger.debug(f"Streaming chunk: {new_chunk[:50]}...")
241
+ # yield {
242
+ # "type": "chunk",
243
+ # "content": new_chunk
244
+ # }
245
+ # current_text = text_content
246
+ # elif text_content != current_text:
247
+ # # Complete text or new text segment
248
+ # new_chunk = text_content[len(current_text):] if current_text else text_content
249
+ # if new_chunk:
250
+ # yield {
251
+ # "type": "chunk",
252
+ # "content": new_chunk
253
+ # }
254
+ # current_text = text_content
255
+ # full_response = text_content
256
+
257
+ # # Handle function calls
258
+ # if hasattr(part, 'function_call'):
259
+ # func_call = part.function_call
260
+ # logger.info(f"Function call detected: {func_call.name if hasattr(func_call, 'name') else 'unknown'}")
261
+
262
+ # # Parse arguments
263
+ # args = {}
264
+ # if hasattr(func_call, 'args'):
265
+ # args = dict(func_call.args) if func_call.args else {}
266
+
267
+ # tool_call_info = {
268
+ # 'tool_name': func_call.name if hasattr(func_call, 'name') else 'unknown',
269
+ # 'arguments': args
270
+ # }
271
+ # self.tool_calls.append(tool_call_info)
272
+
273
+ # yield {
274
+ # "type": "tool_call",
275
+ # "tool_name": tool_call_info['tool_name'],
276
+ # "arguments": tool_call_info['arguments']
277
+ # }
278
+
279
+ # # Handle function responses
280
+ # if hasattr(part, 'function_response'):
281
+ # func_response = part.function_response
282
+ # logger.info(f"Function response for: {func_response.name if hasattr(func_response, 'name') else 'unknown'}")
283
+
284
+ # # Parse the response
285
+ # response_data = func_response.response if hasattr(func_response, 'response') else {}
286
+
287
+ # # Check for images in the response
288
+ # if isinstance(response_data, dict):
289
+ # if 'images' in response_data:
290
+ # images = response_data.get('images', [])
291
+ # if images:
292
+ # self.images_found.extend(images)
293
+ # logger.info(f"Found {len(images)} images from {func_response.name if hasattr(func_response, 'name') else 'tool'}")
294
+
295
+ # yield {
296
+ # "type": "tool_result",
297
+ # "tool_name": func_response.name if hasattr(func_response, 'name') else 'unknown',
298
+ # "result": response_data
299
+ # }
300
+
301
+ # except Exception as async_error:
302
+ # logger.warning(f"Async streaming failed, trying synchronous: {async_error}")
303
+ # # Fallback to synchronous execution
304
+
305
+ # for event in self.runner.run(
306
+ # user_id=self.chat_session.identity,
307
+ # session_id=self.agent_session.id,
308
+ # new_message=query, # Pass query directly as string
309
+ # ):
310
+ # if hasattr(event, 'content') and event.content:
311
+ # if hasattr(event.content, 'parts') and event.content.parts:
312
+ # for part in event.content.parts:
313
+ # if hasattr(part, 'text') and part.text:
314
+ # text_content = part.text
315
+ # if text_content != current_text:
316
+ # new_chunk = text_content[len(current_text):] if current_text else text_content
317
+ # if new_chunk:
318
+ # yield {
319
+ # "type": "chunk",
320
+ # "content": new_chunk
321
+ # }
322
+ # current_text = text_content
323
+ # full_response = text_content
324
+
325
+ # logger.info(f"Agent execution completed.")
326
+ # logger.info(f"Full response length: {len(full_response)}")
327
+ # logger.info(f"Tool calls made: {len(self.tool_calls)}")
328
+ # logger.info(f"Images found: {len(self.images_found)}")
329
+
330
+ # # Process the final response
331
+ # if full_response:
332
+ # # Try to parse as JSON
333
+ # response_text = full_response
334
+ # keywords = []
335
+ # response_images = []
336
+
337
+ # try:
338
+ # # Attempt JSON parsing
339
+ # json_response = json.loads(full_response)
340
+ # response_text = json_response.get('response', full_response)
341
+ # keywords = json_response.get('keywords', [])
342
+ # response_images = json_response.get('images', [])
343
+ # except json.JSONDecodeError:
344
+ # # Not JSON format, use as plain text
345
+ # pass
346
+
347
+ # # Merge all found images
348
+ # all_images = list(set(self.images_found + response_images))
349
+
350
+ # # Save to chat history
351
+ # session_id = self._ensure_valid_session(query)
352
+ # chat_data = {
353
+ # "query": query,
354
+ # "response": response_text,
355
+ # "references": [],
356
+ # "keywords": keywords,
357
+ # "images": all_images,
358
+ # "context": "",
359
+ # "timestamp": datetime.now(timezone.utc).isoformat(),
360
+ # "session_id": session_id,
361
+ # "tool_calls": self.tool_calls
362
+ # }
363
+
364
+ # saved = self.chat_session.save_chat(chat_data)
365
+
366
+ # # Send completion signal
367
+ # yield {
368
+ # "type": "completed",
369
+ # "saved": saved,
370
+ # "session_id": session_id,
371
+ # "keywords": keywords,
372
+ # "images": all_images
373
+ # }
374
+
375
+ # logger.info(f"Chat saved successfully: {saved}")
376
+ # else:
377
+ # logger.warning("No response received from agent")
378
+ # yield {
379
+ # "type": "error",
380
+ # "content": "No response generated"
381
+ # }
382
+
383
+ # except Exception as e:
384
+ # logger.error(f"Error processing message: {e}", exc_info=True)
385
+ # yield {
386
+ # "type": "error",
387
+ # "content": f"Error processing message: {str(e)}"
388
+ # }
389
+
390
+ # def _ensure_valid_session(self, title: str = None) -> str:
391
+ # """Ensure valid chat session exists"""
392
+ # try:
393
+ # if not self.session_id or not self.session_id.strip():
394
+ # logger.info("Creating new session (no session ID provided)")
395
+ # self.chat_session.create_new_session(title=title)
396
+ # self.session_id = self.chat_session.session_id
397
+ # else:
398
+ # try:
399
+ # if not self.chat_session.validate_session(self.session_id, title=title):
400
+ # logger.info(f"Session {self.session_id} invalid, creating new session")
401
+ # self.chat_session.create_new_session(title=title)
402
+ # self.session_id = self.chat_session.session_id
403
+ # except ValueError:
404
+ # logger.info(f"Session {self.session_id} validation failed, creating new session")
405
+ # self.chat_session.create_new_session(title=title)
406
+ # self.session_id = self.chat_session.session_id
407
+
408
+ # logger.info(f"Using session ID: {self.session_id}")
409
+ # return self.session_id
410
+ # except Exception as e:
411
+ # logger.error(f"Error ensuring valid session: {e}", exc_info=True)
412
+ # raise
app/services/agentic_prompt.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict
2
+
3
+
4
+
5
+
6
+
7
+ def _append_personalization(prompt: str, user_data: Dict) -> str:
8
+ personalized_tool = user_data.get('personalized_tool_name')
9
+ if user_data.get('has_personalized_data') and personalized_tool:
10
+ prompt += (
11
+ "\n\n## Personalized Data Access:\n"
12
+ f"Call `{personalized_tool}` exactly once to retrieve the patient's questionnaire-driven context after you finish using the search tools and before writing the final JSON response. "
13
+ "Use only the returned facts when crafting the `## Personalization Recommendation` section. Do not invent details beyond the tool output."
14
+ )
15
+ else:
16
+ prompt += (
17
+ "\n\nNo personalization data is available; omit the `## Personalization Recommendation` section and do not fabricate patient-specific advice."
18
+ )
19
+
20
+ environmental_tool = user_data.get('environmental_tool_name')
21
+ if user_data.get('has_environmental_data') and environmental_tool:
22
+ prompt += (
23
+ "\n\n## Environmental Data Access:\n"
24
+ f"Call `{environmental_tool}` exactly once when environmental guidance is relevant, ideally after the core search tools and personalization step. "
25
+ "Interpret the returned metrics responsibly before presenting them in the `## Environmental Condition` section."
26
+ )
27
+ else:
28
+ prompt += (
29
+ "\n\nEnvironmental conditions are unavailable; omit the `## Environmental Condition` section instead of speculating."
30
+ )
31
+
32
+ if user_data.get('language', 'english').lower() != 'english':
33
+ prompt += (
34
+ f"\n\nRespond in {user_data.get('language')} while keeping the JSON structure intact."
35
+ )
36
+ return prompt
37
+
38
+
39
+ def _format_json_guidance(user_data: Dict) -> str:
40
+ references_instruction = (
41
+ "Populate the `references` array with source links mapped from your tool calls."
42
+ if user_data.get('include_references', True)
43
+ else "Set `references` to an empty array because the user disabled references."
44
+ )
45
+ keywords_instruction = (
46
+ "Provide 4-6 concise medical keywords in the `keywords` array."
47
+ if user_data.get('include_keywords', True)
48
+ else "Set `keywords` to an empty array because the user disabled keywords."
49
+ )
50
+ images_instruction = (
51
+ "Include up to three image URLs in the `images` array when medically relevant."
52
+ if user_data.get('include_images', True)
53
+ else "Set `images` to an empty array because the user disabled images."
54
+ )
55
+
56
+ has_personalization = user_data.get('has_personalized_data')
57
+ has_environmental = user_data.get('has_environmental_data')
58
+ personalized_tool = user_data.get('personalized_tool_name', 'get_personalized_context')
59
+ environmental_tool = user_data.get('environmental_tool_name', 'get_environmental_context')
60
+
61
+ personalization_instruction = (
62
+ f"Include a `## Personalization Recommendation` section that faithfully reflects the data returned by `{personalized_tool}`."
63
+ if has_personalization
64
+ else "Omit the `## Personalization Recommendation` section because no personalization tool data is available."
65
+ )
66
+ environmental_instruction = (
67
+ f"Include a `## Environmental Condition` section only when `{environmental_tool}` returns data; otherwise omit the section instead of speculating."
68
+ if has_environmental
69
+ else "Omit the `## Environmental Condition` section because environmental data is unavailable."
70
+ )
71
+ safety_instruction = (
72
+ "If the question is outside dermatology/medical scope or evidence is insufficient, respond with a safe refusal inside the JSON instead of guessing."
73
+ )
74
+ return (
75
+ "\nYour final response MUST be valid JSON with this shape:\n"
76
+ "{\n"
77
+ " \"response\": \"Always start with `## Response from References`. Add `## Personalization Recommendation` only after ingesting personalization tool data, and add `## Environmental Condition` only after ingesting environmental tool data. Under each heading, report only evidence-backed details with inline citations like [1], [2].\",\n"
78
+ " \"keywords\": [\"keyword1\", \"keyword2\", ...],\n"
79
+ " \"references\": [\"url_or_source_1\", ...],\n"
80
+ " \"images\": [\"image_url_1\", ...]\n"
81
+ "}\n"
82
+ f"{references_instruction}\n"
83
+ f"{keywords_instruction}\n"
84
+ f"{images_instruction}\n"
85
+ f"{personalization_instruction}\n"
86
+ f"{environmental_instruction}\n"
87
+ f"{safety_instruction}\n"
88
+ "Return only JSON (no prose outside the JSON object).\n"
89
+ "Do NOT hallucinate or invent facts.\n"
90
+ )
91
+
92
+
93
+ def get_web_search_prompt(user_data: Dict) -> str:
94
+ prompt = """
95
+ You are Dr. DermAI, an evidence-based dermatology consultant.
96
+
97
+ PRIMARY DIRECTIVES:
98
+ 1. ALWAYS call the `get_web_search` tool first to gather the latest medical knowledge.
99
+ 2. After reviewing text sources, call `get_image_search` if images are permitted to enrich the answer.
100
+ 3. Base every statement on retrieved sources; do not rely solely on prior training.
101
+ 4. Cite the supporting evidence inline using [1], [2], etc.
102
+ 5. Answer ONLY dermatology or medically relevant queries. Politely refuse others inside the JSON response.
103
+ 6. If the retrieved evidence is insufficient, acknowledge the limitation rather than speculating.
104
+
105
+ TOOL CALL ORDER:
106
+ - Step 1: get_web_search(query=<user question>)
107
+ - Step 2: get_image_search(query=<key medical term>) when images are allowed
108
+ - Step 3: Synthesize findings into the structured JSON response.
109
+ """
110
+ recent_history = user_data.get('recent_history')
111
+ if recent_history:
112
+ prompt += (
113
+ "\n\n## Recent Conversation Context (most recent first)\n"
114
+ f"{recent_history}\n"
115
+ "Use this context to maintain continuity before answering the new query."
116
+ )
117
+ prompt += _format_json_guidance(user_data)
118
+ prompt = _append_personalization(prompt, user_data)
119
+ return prompt.strip()
120
+
121
+
122
+ def get_vector_search_prompt(user_data: Dict) -> str:
123
+ prompt = """
124
+ You are Dr. DermAI, a dermatologist with access to a curated clinical knowledge base.
125
+
126
+ PRIMARY DIRECTIVES:
127
+ 1. ALWAYS call the `get_vector_search` tool first to retrieve authoritative dermatology passages.
128
+ 2. After reviewing text sources, call `get_image_search` if images are permitted.
129
+ 3. Ground every recommendation in the retrieved evidence and cite it inline using [1], [2], etc.
130
+ 4. If the knowledge base lacks coverage, state this explicitly and provide a best-effort safety answer.
131
+ 5. Answer ONLY dermatology or medically relevant queries. Politely refuse others inside the JSON response.
132
+ 6. If evidence is insufficient, acknowledge the limitation instead of speculating.
133
+
134
+ TOOL CALL ORDER:
135
+ - Step 1: get_vector_search(query=<user question>)
136
+ - Step 2: get_image_search(query=<key medical term>) when images are allowed
137
+ - Step 3: Synthesize findings into the structured JSON response.
138
+ """
139
+ recent_history = user_data.get('recent_history')
140
+ if recent_history:
141
+ prompt += (
142
+ "\n\n## Recent Conversation Context (most recent first)\n"
143
+ f"{recent_history}\n"
144
+ "Use this context to maintain continuity before answering the new query."
145
+ )
146
+ prompt += _format_json_guidance(user_data)
147
+ prompt = _append_personalization(prompt, user_data)
148
+ return prompt.strip()
app/services/google_agent_service.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import re
6
+ from datetime import datetime, timezone
7
+ from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Tuple
8
+
9
+ from google.adk.agents import Agent
10
+ from google.adk.agents.run_config import RunConfig, StreamingMode
11
+ from google.adk.runners import InMemoryRunner
12
+ from google.adk.tools import FunctionTool
13
+ from google.genai import types
14
+
15
+ from app.services.agentic_prompt import (
16
+ get_vector_search_prompt,
17
+ get_web_search_prompt,
18
+ )
19
+ from app.services.chathistory import ChatSession
20
+ from app.services.environmental_condition import EnvironmentalData
21
+ from app.services.tools import (
22
+ get_image_search,
23
+ get_vector_search,
24
+ get_web_search,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
30
+ DEFAULT_MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp")
31
+
32
+ PERSONALIZED_TOOL_NAME = "get_personalized_context"
33
+ ENVIRONMENT_TOOL_NAME = "get_environmental_context"
34
+
35
+ if not os.getenv("GOOGLE_API_KEY") and GOOGLE_API_KEY:
36
+ os.environ["GOOGLE_API_KEY"] = GOOGLE_API_KEY
37
+
38
+ if os.name == "nt":
39
+ try:
40
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
41
+ except Exception: # pragma: no cover
42
+ pass
43
+
44
+
45
+ class GoogleAgentService:
46
+ """Chat orchestrator that streams responses from a Google ADK agent."""
47
+
48
+ def __init__(self, token: str, session_id: Optional[str] = None) -> None:
49
+ self.token = token
50
+ self.session_id = session_id
51
+ self.chat_session = ChatSession(token, session_id)
52
+
53
+ self.user_preferences = self._load_user_preferences()
54
+ self.language = self.chat_session.get_language() or "english"
55
+ self.user_profile = self._load_user_profile()
56
+ self.user_city = self.chat_session.get_city()
57
+ self.environment_data = self._load_environmental_data()
58
+
59
+ async def process_message_async(
60
+ self, query: str
61
+ ) -> AsyncGenerator[Dict[str, Any], None]:
62
+ if not GOOGLE_API_KEY:
63
+ error = "Google API key is not configured."
64
+ logger.error(error)
65
+ yield {"type": "error", "content": error}
66
+ return
67
+
68
+ try:
69
+ session_id = self._ensure_valid_session(query)
70
+ agent_mode = "web" if self.user_preferences.get("websearch") else "vector"
71
+ user_data = self._prepare_user_data()
72
+ agent = self._build_agent(agent_mode, user_data)
73
+ runner = InMemoryRunner(agent=agent)
74
+
75
+ await runner.session_service.create_session(
76
+ app_name=runner.app_name,
77
+ user_id=self.chat_session.identity,
78
+ session_id=session_id,
79
+ )
80
+
81
+ user_message = types.Content(
82
+ role="user",
83
+ parts=[types.Part(text=query)],
84
+ )
85
+
86
+ run_config = RunConfig(streaming_mode=StreamingMode.SSE)
87
+
88
+ tool_calls: List[Dict[str, Any]] = []
89
+ tool_call_map: Dict[str, Dict[str, Any]] = {}
90
+ collected_images: List[str] = []
91
+ collected_references: List[str] = []
92
+ streamed_text = ""
93
+ final_text = ""
94
+ pending_token_buffer = ""
95
+
96
+ def emit_word_chunks(delta: str, *, final: bool = False) -> List[str]:
97
+ nonlocal pending_token_buffer
98
+
99
+ pending_token_buffer += delta
100
+ chunks: List[str] = []
101
+
102
+ while pending_token_buffer:
103
+ match = re.search(r'\s', pending_token_buffer)
104
+ if not match:
105
+ break
106
+ idx = match.end()
107
+ token = pending_token_buffer[:idx]
108
+ pending_token_buffer = pending_token_buffer[idx:]
109
+ if token:
110
+ chunks.append(token)
111
+
112
+ if final and pending_token_buffer:
113
+ chunks.append(pending_token_buffer)
114
+ pending_token_buffer = ""
115
+
116
+ return chunks
117
+
118
+ async for event in runner.run_async(
119
+ user_id=self.chat_session.identity,
120
+ session_id=session_id,
121
+ new_message=user_message,
122
+ run_config=run_config,
123
+ ):
124
+ if event.error_message:
125
+ logger.error("Agent error: %s", event.error_message)
126
+ yield {"type": "error", "content": event.error_message}
127
+ return
128
+
129
+ for function_call in event.get_function_calls():
130
+ call_entry = {
131
+ "id": function_call.id,
132
+ "tool_name": function_call.name,
133
+ "arguments": function_call.args or {},
134
+ }
135
+ tool_call_map[function_call.id] = call_entry
136
+ tool_calls.append(call_entry)
137
+ yield {
138
+ "type": "tool_call",
139
+ "id": function_call.id,
140
+ "tool_name": function_call.name,
141
+ "arguments": function_call.args or {},
142
+ }
143
+
144
+ for function_response in event.get_function_responses():
145
+ response_payload = function_response.response or {}
146
+ call_entry = tool_call_map.get(function_response.id)
147
+ if call_entry is not None:
148
+ call_entry["result"] = response_payload
149
+ if isinstance(response_payload, dict):
150
+ if function_response.name == "get_image_search":
151
+ collected_images.extend(response_payload.get("images", []))
152
+ if response_payload.get("references"):
153
+ collected_references.extend(response_payload["references"])
154
+ yield {
155
+ "type": "tool_result",
156
+ "id": function_response.id,
157
+ "tool_name": function_response.name,
158
+ "result": response_payload,
159
+ }
160
+
161
+ text_segment = self._extract_text(event)
162
+ if not text_segment:
163
+ continue
164
+
165
+ if event.partial:
166
+ streamed_text += text_segment
167
+ for token in emit_word_chunks(text_segment):
168
+ yield {"type": "chunk", "content": token}
169
+ else:
170
+ final_text = text_segment
171
+ if streamed_text and text_segment.startswith(streamed_text):
172
+ delta = text_segment[len(streamed_text) :]
173
+ else:
174
+ delta = text_segment
175
+ if text_segment:
176
+ streamed_text = text_segment
177
+ for token in emit_word_chunks(delta, final=True):
178
+ yield {"type": "chunk", "content": token}
179
+
180
+ for leftover in emit_word_chunks("", final=True):
181
+ if leftover:
182
+ yield {"type": "chunk", "content": leftover}
183
+
184
+
185
+ parsed_response = self._parse_agent_response(final_text or streamed_text)
186
+ response_text, keywords, response_images, response_refs = parsed_response
187
+
188
+ merged_images = self._dedupe_list(collected_images + response_images)
189
+ merged_references = self._dedupe_list(collected_references + response_refs)
190
+
191
+ chat_payload = {
192
+ "query": query,
193
+ "response": response_text,
194
+ "references": merged_references,
195
+ "keywords": keywords,
196
+ "images": merged_images,
197
+ "context": "",
198
+ "timestamp": datetime.now(timezone.utc).isoformat(),
199
+ "session_id": session_id,
200
+ "tool_calls": tool_calls,
201
+ }
202
+ saved = self.chat_session.save_chat(chat_payload)
203
+
204
+ yield {
205
+ "type": "completed",
206
+ "saved": saved,
207
+ "session_id": session_id,
208
+ "response": response_text,
209
+ "keywords": keywords,
210
+ "references": merged_references,
211
+ "images": merged_images,
212
+ }
213
+ except Exception as exc:
214
+ logger.error("Agent streaming failure: %s", exc, exc_info=True)
215
+ yield {"type": "error", "content": f"Generation failed: {exc}"}
216
+
217
+
218
+ def _build_agent(self, mode: str, user_data: Dict[str, Any]) -> Agent:
219
+ prompt = (
220
+ get_web_search_prompt(user_data)
221
+ if mode == "web"
222
+ else get_vector_search_prompt(user_data)
223
+ )
224
+ search_tool = get_web_search if mode == "web" else get_vector_search
225
+ tools = [
226
+ FunctionTool(search_tool),
227
+ FunctionTool(get_image_search),
228
+ ]
229
+
230
+ if user_data.get("has_personalized_data"):
231
+ personalized_tool = self._create_personalized_data_tool(
232
+ user_data.get("personalized_data", "")
233
+ )
234
+ tools.append(FunctionTool(personalized_tool))
235
+
236
+ if user_data.get("has_environmental_data"):
237
+ environmental_tool = self._create_environmental_data_tool(
238
+ user_data.get("environmental_payload") or {}
239
+ )
240
+ tools.append(FunctionTool(environmental_tool))
241
+
242
+ agent = Agent(
243
+ name="DermAI",
244
+ model=DEFAULT_MODEL_NAME,
245
+ instruction=prompt,
246
+ tools=tools,
247
+ )
248
+ return agent
249
+
250
+ def _create_personalized_data_tool(self, data: str) -> Callable[[], Dict[str, Any]]:
251
+ sanitized = (data or "").strip()
252
+
253
+ def personalized_tool() -> Dict[str, Any]:
254
+ return {
255
+ "status": "success",
256
+ "generated_at": datetime.now(timezone.utc).isoformat(),
257
+ "personalized_data": sanitized,
258
+ }
259
+
260
+ personalized_tool.__name__ = PERSONALIZED_TOOL_NAME
261
+ personalized_tool.__doc__ = (
262
+ "Return questionnaire-derived personalization details for the current user."
263
+ )
264
+ return personalized_tool
265
+
266
+ def _create_environmental_data_tool(
267
+ self, data: Dict[str, Any]
268
+ ) -> Callable[[], Dict[str, Any]]:
269
+ snapshot = dict(data) if isinstance(data, dict) else {}
270
+ city = self.user_city
271
+
272
+ def environmental_tool() -> Dict[str, Any]:
273
+ return {
274
+ "status": "success",
275
+ "city": city,
276
+ "retrieved_at": datetime.now(timezone.utc).isoformat(),
277
+ "environmental_data": snapshot,
278
+ }
279
+
280
+ environmental_tool.__name__ = ENVIRONMENT_TOOL_NAME
281
+ environmental_tool.__doc__ = (
282
+ "Return the cached environmental conditions for the user's location."
283
+ )
284
+ return environmental_tool
285
+
286
+ def _load_user_preferences(self) -> Dict[str, Any]:
287
+ try:
288
+ return self.chat_session.get_user_preferences()
289
+ except Exception as exc:
290
+ logger.warning("Failed to load user preferences: %s", exc)
291
+ return {
292
+ "websearch": False,
293
+ "keywords": True,
294
+ "references": True,
295
+ "personalized_recommendations": False,
296
+ "environmental_recommendations": False,
297
+ }
298
+
299
+ def _load_user_profile(self) -> Dict[str, Any]:
300
+ try:
301
+ profile = self.chat_session.get_name_and_age() or {}
302
+ return {
303
+ "name": profile.get("name", "Patient"),
304
+ "age": profile.get("age", "Unknown"),
305
+ }
306
+ except Exception as exc:
307
+ logger.warning("Failed to load profile: %s", exc)
308
+ return {"name": "Patient", "age": "Unknown"}
309
+
310
+
311
+ def _load_environmental_data(self) -> Optional[Dict[str, Any]]:
312
+ try:
313
+ if (
314
+ self.user_preferences.get("environmental_recommendations")
315
+ and self.user_city
316
+ ):
317
+ data = EnvironmentalData(self.user_city).get_environmental_data()
318
+ if data:
319
+ return data
320
+ except Exception as exc:
321
+ logger.warning("Failed to load environmental data: %s", exc)
322
+ return None
323
+
324
+ def _load_personalized_data(self) -> str:
325
+ try:
326
+ if self.user_preferences.get("personalized_recommendations"):
327
+ data = self.chat_session.get_personalized_recommendation()
328
+ return data or ""
329
+ except Exception as exc:
330
+ logger.warning("Failed to load personalized data: %s", exc)
331
+ return ""
332
+
333
+
334
+ def _prepare_user_data(self) -> Dict[str, Any]:
335
+ personalized_data = self._load_personalized_data()
336
+ environmental_payload = (
337
+ dict(self.environment_data)
338
+ if isinstance(self.environment_data, dict)
339
+ else {}
340
+ )
341
+ has_personalized_data = bool(personalized_data)
342
+ has_environmental_data = bool(environmental_payload)
343
+
344
+ return {
345
+ "name": self.user_profile.get("name"),
346
+ "age": self.user_profile.get("age"),
347
+ "language": self.language,
348
+ "personalized_recommendations": self.user_preferences.get(
349
+ "personalized_recommendations"
350
+ ),
351
+ "environmental_recommendations": self.user_preferences.get(
352
+ "environmental_recommendations"
353
+ ),
354
+ "personalized_data": personalized_data,
355
+ "environmental_data": json.dumps(environmental_payload)
356
+ if has_environmental_data
357
+ else "",
358
+ "has_personalized_data": has_personalized_data,
359
+ "has_environmental_data": has_environmental_data,
360
+ "personalized_tool_name": PERSONALIZED_TOOL_NAME
361
+ if has_personalized_data
362
+ else None,
363
+ "environmental_tool_name": ENVIRONMENT_TOOL_NAME
364
+ if has_environmental_data
365
+ else None,
366
+ "environmental_payload": environmental_payload,
367
+ "include_keywords": self.user_preferences.get("keywords", True),
368
+ "include_references": self.user_preferences.get("references", True),
369
+ "include_images": True,
370
+ "recent_history": self._get_recent_history(),
371
+ }
372
+
373
+ def _get_recent_history(self, limit: int = 10) -> str:
374
+ try:
375
+ if not self.session_id:
376
+ return ""
377
+ self.chat_session.load_chat_history()
378
+ history_items = self.chat_session.get_chat_history() or []
379
+ if not history_items:
380
+ return ""
381
+ recent = history_items[-limit:]
382
+ formatted = []
383
+ for entry in recent:
384
+ user_q = entry.get("query") or ""
385
+ bot_a = entry.get("response") or ""
386
+ if user_q:
387
+ formatted.append(f"User: {user_q}")
388
+ if bot_a:
389
+ formatted.append(f"Dr DermAI: {bot_a}")
390
+ return "\n".join(formatted[-limit * 2:])
391
+ except Exception as exc:
392
+ logger.warning("Failed to load recent history: %s", exc)
393
+ return ""
394
+
395
+ def _ensure_valid_session(self, title: Optional[str] = None) -> str:
396
+ if not self.session_id or not self.session_id.strip():
397
+ self.chat_session.create_new_session(title=title)
398
+ self.session_id = self.chat_session.session_id
399
+ else:
400
+ try:
401
+ if not self.chat_session.validate_session(self.session_id, title=title):
402
+ self.chat_session.create_new_session(title=title)
403
+ self.session_id = self.chat_session.session_id
404
+ except Exception:
405
+ self.chat_session.create_new_session(title=title)
406
+ self.session_id = self.chat_session.session_id
407
+ return self.session_id
408
+
409
+ @staticmethod
410
+ def _extract_text(event) -> str:
411
+ if not event.content or not event.content.parts:
412
+ return ""
413
+ parts: List[str] = []
414
+ for part in event.content.parts:
415
+ if part.text:
416
+ parts.append(part.text)
417
+ return "".join(parts)
418
+
419
+ @staticmethod
420
+ def _strip_code_fence(text: str) -> str:
421
+ stripped = text.strip()
422
+ if stripped.startswith("```") and stripped.endswith("```"):
423
+ body = stripped.strip("`")
424
+ if body.lower().startswith("json"):
425
+ body = body[4:]
426
+ stripped = body
427
+ return stripped.strip()
428
+
429
+ def _parse_agent_response(self, text: str) -> Tuple[str, List[str], List[str], List[str]]:
430
+ cleaned = self._strip_code_fence(text)
431
+ if not cleaned:
432
+ return "", [], [], []
433
+ try:
434
+ payload = json.loads(cleaned)
435
+ except json.JSONDecodeError:
436
+ logger.warning("Unable to parse agent JSON response; returning raw text.")
437
+ return cleaned, [], [], []
438
+ if not isinstance(payload, dict):
439
+ return cleaned, [], [], []
440
+
441
+ response_text = payload.get("response") or cleaned
442
+
443
+ raw_keywords = payload.get("keywords", [])
444
+ if isinstance(raw_keywords, list):
445
+ keywords = [str(item).strip() for item in raw_keywords if str(item).strip()]
446
+ elif raw_keywords:
447
+ keywords = [str(raw_keywords).strip()]
448
+ else:
449
+ keywords = []
450
+
451
+ raw_images = payload.get("images", [])
452
+ if isinstance(raw_images, list):
453
+ images = [str(item).strip() for item in raw_images if str(item).strip()]
454
+ elif raw_images:
455
+ images = [str(raw_images).strip()]
456
+ else:
457
+ images = []
458
+
459
+ raw_refs = payload.get("references", [])
460
+ if isinstance(raw_refs, list):
461
+ references = [str(item).strip() for item in raw_refs if str(item).strip()]
462
+ elif raw_refs:
463
+ references = [str(raw_refs).strip()]
464
+ else:
465
+ references = []
466
+
467
+ return response_text, keywords, images, references
468
+
469
+ @staticmethod
470
+ def _dedupe_list(items: List[str]) -> List[str]:
471
+ seen = set()
472
+ deduped: List[str] = []
473
+ for item in items:
474
+ if not item:
475
+ continue
476
+ if item in seen:
477
+ continue
478
+ seen.add(item)
479
+ deduped.append(item)
480
+ return deduped
481
+
482
+
483
+
484
+
485
+
app/services/llm_model.py CHANGED
@@ -1,12 +1,12 @@
1
  import json
2
- from google import genai
3
  from dotenv import load_dotenv
4
  import os
5
- from google import genai
6
- from google.genai import types
7
  import re
8
  from g4f.client import Client
9
- from google.genai.types import GenerateContentConfig, HttpOptions
10
 
11
  load_dotenv()
12
 
 
1
  import json
2
+ # from google import genai
3
  from dotenv import load_dotenv
4
  import os
5
+ # from google import genai
6
+ # from google.genai import types
7
  import re
8
  from g4f.client import Client
9
+ # from google.genai.types import GenerateContentConfig, HttpOptions
10
 
11
  load_dotenv()
12
 
app/services/tools.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ο»Ώimport logging
2
+ from typing import Any, Dict, List
3
+
4
+ from app.services.vector_database_search import VectorDatabaseSearch
5
+ from app.services.websearch import WebSearch
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def _clean_query(query: str) -> str:
11
+ return (query or '').strip()
12
+
13
+
14
+ def get_web_search(query: str, num_results: int = 4) -> Dict[str, Any]:
15
+ """Return up-to-date dermatology information from the public web."""
16
+ query = _clean_query(query)
17
+ if not query:
18
+ return {"status": "error", "error_message": "Query is required."}
19
+
20
+ try:
21
+ web = WebSearch(num_results=max(1, min(num_results or 4, 8)))
22
+ raw_results = web.search(query) or []
23
+
24
+ formatted: List[Dict[str, Any]] = []
25
+ references: List[str] = []
26
+ for idx, item in enumerate(raw_results, start=1):
27
+ link = item.get('link') or item.get('url') or ''
28
+ snippet = item.get('text') or item.get('snippet') or ''
29
+ title = item.get('title') or ''
30
+ entry = {
31
+ "source_number": idx,
32
+ "title": title,
33
+ "link": link,
34
+ "snippet": snippet,
35
+ }
36
+ formatted.append(entry)
37
+ if link:
38
+ references.append(link)
39
+
40
+ if not formatted:
41
+ return {
42
+ "status": "error",
43
+ "error_message": f"No web results found for '{query}'.",
44
+ }
45
+
46
+ return {
47
+ "status": "success",
48
+ "results": formatted,
49
+ "references": references,
50
+ }
51
+ except Exception as exc:
52
+ logger.exception("Web search failed: %s", exc)
53
+ return {
54
+ "status": "error",
55
+ "error_message": f"Web search failed: {exc}",
56
+ }
57
+
58
+
59
+ def get_vector_search(query: str, top_k: int = 5) -> Dict[str, Any]:
60
+ """Return dermatology knowledge from the curated vector database."""
61
+ query = _clean_query(query)
62
+ if not query:
63
+ return {"status": "error", "error_message": "Query is required."}
64
+
65
+ try:
66
+ vector = VectorDatabaseSearch()
67
+ if not vector.is_available():
68
+ return {
69
+ "status": "error",
70
+ "error_message": "Vector database is not available.",
71
+ }
72
+
73
+ raw_results = vector.search(query, top_k=max(1, min(top_k or 5, 10)))
74
+ if not raw_results:
75
+ return {
76
+ "status": "error",
77
+ "error_message": f"No vector results found for '{query}'.",
78
+ }
79
+
80
+ formatted: List[Dict[str, Any]] = []
81
+ references: List[str] = []
82
+ for idx, item in enumerate(raw_results, start=1):
83
+ source = item.get('source') or 'Unknown'
84
+ page = item.get('page') or 0
85
+ content = item.get('content') or ''
86
+ confidence = item.get('confidence')
87
+ formatted.append(
88
+ {
89
+ "source_number": idx,
90
+ "source": source,
91
+ "page": page,
92
+ "content": content,
93
+ "confidence": confidence,
94
+ }
95
+ )
96
+ ref_label = f"{source} (page {page})" if page else source
97
+ references.append(ref_label)
98
+
99
+ return {
100
+ "status": "success",
101
+ "results": formatted,
102
+ "references": references,
103
+ }
104
+ except Exception as exc:
105
+ logger.exception("Vector search failed: %s", exc)
106
+ return {
107
+ "status": "error",
108
+ "error_message": f"Vector search failed: {exc}",
109
+ }
110
+
111
+
112
+ def get_image_search(query: str, max_images: int = 3) -> Dict[str, Any]:
113
+ """Return dermatology-relevant image URLs for the given query."""
114
+ query = _clean_query(query)
115
+ if not query:
116
+ return {"status": "error", "error_message": "Query is required."}
117
+
118
+ try:
119
+ searcher = WebSearch(max_images=max(1, min(max_images or 3, 8)))
120
+ images = searcher.search_images(query) or []
121
+ unique_images = []
122
+ seen = set()
123
+ for url in images:
124
+ if url and url not in seen:
125
+ seen.add(url)
126
+ unique_images.append(url)
127
+ if len(unique_images) >= max_images:
128
+ break
129
+
130
+ if not unique_images:
131
+ return {
132
+ "status": "error",
133
+ "error_message": f"No images found for '{query}'.",
134
+ }
135
+
136
+ return {"status": "success", "images": unique_images}
137
+ except Exception as exc:
138
+ logger.exception("Image search failed: %s", exc)
139
+ return {
140
+ "status": "error",
141
+ "error_message": f"Image search failed: {exc}",
142
+ }
document_code.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ def generate_tree(path, prefix=""):
5
+ """Generate tree structure"""
6
+ items = []
7
+ try:
8
+ entries = sorted(path.iterdir(), key=lambda x: (x.is_file(), x.name))
9
+ # Filter out ignored entries first
10
+ filtered_entries = [e for e in entries if not should_ignore(e)]
11
+
12
+ for i, entry in enumerate(filtered_entries):
13
+ is_last = i == len(filtered_entries) - 1
14
+ current_prefix = "└── " if is_last else "β”œβ”€β”€ "
15
+
16
+ items.append(f"{prefix}{current_prefix}{entry.name}")
17
+
18
+ if entry.is_dir():
19
+ next_prefix = prefix + (" " if is_last else "β”‚ ")
20
+ items.extend(generate_tree(entry, next_prefix))
21
+ except PermissionError:
22
+ pass
23
+ return items
24
+
25
+ def should_ignore(path):
26
+ """Check if file/folder should be ignored"""
27
+ # Explicitly check for virtual environments and common ignored folders
28
+ if path.name in {'.venv', 'venv', '__pycache__', '.git', 'node_modules', '.idea', '.vscode'}:
29
+ return True
30
+
31
+ # Check if file is inside .venv or venv folder
32
+ if '.venv' in path.parts or 'venv' in path.parts:
33
+ return True
34
+
35
+ # Ignore all hidden files/folders (starting with .)
36
+ if path.name.startswith('.'):
37
+ return True
38
+
39
+ # Ignore specific files
40
+ ignore_files = {
41
+ 'CODE_DOCUMENTATION.md', 'CODE_DOCUMENTATION.ipynb',
42
+ 'CODE_DOCUMENTATION.html', 'CODE_DOCUMENTATION.pdf',
43
+ 'CODE_DOCUMENTATION.docx', 'CODE_DOCUMENTATION.txt',
44
+ 'CODE_DOCUMENTATION.csv', 'CODE_DOCUMENTATION.xlsx',
45
+ 'CODE_DOCUMENTATION.pptx', 'CODE_DOCUMENTATION.ods',
46
+ 'CODE_DOCUMENTATION.odp', 'CODE_DOCUMENTATION.odt',
47
+ 'uv.lock', 'poetry.lock', 'Pipfile.lock',
48
+ '.DS_Store'
49
+ }
50
+
51
+ # Ignore by file extension
52
+ ignore_extensions = {'.pyc', '.pyo', '.pyd', '.so', '.egg-info'}
53
+
54
+ return (path.name in ignore_files or
55
+ path.suffix in ignore_extensions or
56
+ path.name.endswith('.egg-info'))
57
+
58
+ def get_code_files(directory):
59
+ """Get all relevant code files"""
60
+ code_extensions = {'.py', '.js', '.ts', '.html', '.css', '.sql', '.yaml', '.yml', '.json', '.toml', '.cfg', '.ini'}
61
+ code_files = []
62
+
63
+ for file_path in directory.rglob("*"):
64
+ # Skip if it's a directory
65
+ if file_path.is_dir():
66
+ continue
67
+
68
+ # Skip ignored files/folders
69
+ if should_ignore(file_path):
70
+ continue
71
+
72
+ # Only include files with relevant extensions
73
+ if file_path.suffix.lower() in code_extensions:
74
+ code_files.append(file_path)
75
+
76
+ return sorted(code_files)
77
+
78
+ def main():
79
+ current_dir = Path.cwd()
80
+ output_file = current_dir / "CODE_DOCUMENTATION.md"
81
+
82
+ # Generate markdown
83
+ markdown = f"# {current_dir.name}\n\n"
84
+ markdown += f"Generated on: {Path.cwd()}\n\n"
85
+
86
+ # Add tree structure
87
+ markdown += "## Project Structure\n\n```\n"
88
+ markdown += f"{current_dir.name}/\n"
89
+ tree_items = generate_tree(current_dir)
90
+ for item in tree_items:
91
+ markdown += f"{item}\n"
92
+ markdown += "```\n\n"
93
+
94
+ # Get all code files
95
+ code_files = get_code_files(current_dir)
96
+
97
+ if code_files:
98
+ markdown += "## Source Code\n\n"
99
+
100
+ for file_path in code_files:
101
+ try:
102
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
103
+ content = f.read()
104
+
105
+ rel_path = file_path.relative_to(current_dir)
106
+ file_extension = file_path.suffix.lstrip('.')
107
+
108
+ # Use appropriate syntax highlighting
109
+ if file_extension == 'py':
110
+ lang = 'python'
111
+ elif file_extension in ['js', 'ts']:
112
+ lang = 'javascript'
113
+ elif file_extension in ['html']:
114
+ lang = 'html'
115
+ elif file_extension in ['css']:
116
+ lang = 'css'
117
+ elif file_extension in ['sql']:
118
+ lang = 'sql'
119
+ elif file_extension in ['yaml', 'yml']:
120
+ lang = 'yaml'
121
+ elif file_extension in ['json']:
122
+ lang = 'json'
123
+ else:
124
+ lang = file_extension
125
+
126
+ markdown += f"### {rel_path}\n\n"
127
+ markdown += f"```{lang}\n{content}\n```\n\n"
128
+
129
+ except Exception as e:
130
+ markdown += f"### {rel_path}\n\n"
131
+ markdown += f"*Could not read file: {str(e)}*\n\n"
132
+ continue
133
+ else:
134
+ markdown += "## Source Code\n\n*No code files found.*\n\n"
135
+
136
+ # Write output
137
+ try:
138
+ with open(output_file, 'w', encoding='utf-8') as f:
139
+ f.write(markdown)
140
+ print(f"βœ… Documentation generated successfully: {output_file}")
141
+ print(f"πŸ“ Total files documented: {len(code_files)}")
142
+ except Exception as e:
143
+ print(f"❌ Error writing documentation: {str(e)}")
144
+
145
+ if __name__ == "__main__":
146
+ main()
pyproject.toml CHANGED
@@ -8,7 +8,7 @@ authors = [
8
  dependencies = [
9
  "beautifulsoup4==4.13.4",
10
  "fastapi==0.115.12",
11
- "google-genai==1.13.0",
12
  "huggingface_hub==0.30.2",
13
  "langchain_community==0.3.23",
14
  "langchain_google_genai==2.1.4",
@@ -41,7 +41,8 @@ dependencies = [
41
  "python-pptx==1.0.2",
42
  "puremagic==1.28",
43
  "charset-normalizer==3.4.1",
44
- "pytesseract==0.3.13"
 
45
  ]
46
 
47
  [build-system]
 
8
  dependencies = [
9
  "beautifulsoup4==4.13.4",
10
  "fastapi==0.115.12",
11
+ "google-genai==1.36.0",
12
  "huggingface_hub==0.30.2",
13
  "langchain_community==0.3.23",
14
  "langchain_google_genai==2.1.4",
 
41
  "python-pptx==1.0.2",
42
  "puremagic==1.28",
43
  "charset-normalizer==3.4.1",
44
+ "pytesseract==0.3.13",
45
+ "langchain-google-genai"
46
  ]
47
 
48
  [build-system]