Spaces:
Sleeping
Sleeping
Commit
Β·
540cf5a
1
Parent(s):
e02b28a
update
Browse files- .gitignore +130 -15
- CODE_DOCUMENTATION.md +0 -0
- Makefile +27 -0
- app/main.py +3 -1
- app/routers/agent_chat.py +115 -0
- app/services/agent_service.py +412 -0
- app/services/agentic_prompt.py +148 -0
- app/services/google_agent_service.py +485 -0
- app/services/llm_model.py +4 -4
- app/services/tools.py +142 -0
- document_code.py +146 -0
- pyproject.toml +3 -2
.gitignore
CHANGED
|
@@ -1,14 +1,12 @@
|
|
| 1 |
-
#
|
| 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 |
-
#
|
| 30 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
venv/
|
| 32 |
ENV/
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
#
|
| 35 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
.vscode/
|
|
|
|
| 37 |
*.swp
|
| 38 |
*.swo
|
|
|
|
| 39 |
.DS_Store
|
| 40 |
|
| 41 |
-
#
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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]
|