GrantForge Bot
Deploy to Hugging Face
afd56bc
from fastapi import APIRouter, Depends, HTTPException
from core.search.grant_search_service import grant_search_service
import asyncio
import json
from pydantic import BaseModel, Field
from endpoints.projects import get_db
from core.projects.models import Project
from core.subscription.middleware import verify_token
from core.llm_router import get_llm
from langchain_core.prompts import PromptTemplate
from typing import List
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/grants", tags=["grants"])
@router.get("/nabory")
async def get_nabory(force_refresh: bool = False):
"""
Pobiera list臋 aktualnych nabor贸w dla program贸w z PARP i NCBR.
艁膮czy wyniki z obu instytucji w jedn膮 ujednolicon膮 list臋.
Dodatkowo weryfikuje baz臋 prawn膮 przez integracj臋 z EUR-Lex.
"""
try:
nabory = await grant_search_service.get_all_grants(force_refresh=force_refresh)
except Exception as e:
logger.error(f"Error fetching grants: {e}")
nabory = []
# Sortuj rosn膮co po dacie deadline (najbli偶sze najpierw)
# deadline mo偶e by膰 w formacie YYYY-MM-DD
def get_deadline(item):
d = item.get("deadline")
return d if d else "9999-12-31"
nabory.sort(key=get_deadline)
# Wyczy艣膰 sztuczny deadline przed wys艂aniem na frontend
for item in nabory:
if item.get("deadline") == "9999-12-31":
item["deadline"] = ""
return {
"status": "ok",
"count": len(nabory),
"nabory": nabory
}
class GrantMatchResult(BaseModel):
program_id: str = Field(description="Identyfikator naboru z dostarczonej listy")
program_name: str = Field(description="Nazwa programu")
score: int = Field(description="Ocena dopasowania w skali 0-100 na podstawie opisu projektu")
rationale: str = Field(description="Kr贸tkie uzasadnienie (do 3 zda艅) dlaczego program pasuje lub nie pasuje do projektu")
is_recommended: bool = Field(description="Czy program jest rekomendowany (przyznaj True je艣li score >= 70)")
requires_verification: bool = Field(default=False, description="Ustaw na True, je艣li wniosek wymaga weryfikacji manualnej, np. z powodu niepewnego statusu M艢P, s艂abego wyniku (score < 85) lub niepewnej kwalifikowalno艣ci.")
source: str = Field(default="N/A", description="殴r贸d艂o informacji o naborze (np. parp_verified_fallback), przepisz z dostarczonych wytycznych.")
legal_basis: str = Field(default="N/A", description="Podstawa prawna / regulamin naboru, przepisz z dostarczonych wytycznych.")
confidence_score: int = Field(default=0, description="Ocena pewno艣ci sztucznej inteligencji co do tego dopasowania (0-100).")
class AdvancedMatchResponseData(BaseModel):
needs_more_info: bool = Field(description="True, je艣li brakuje kluczowych informacji do rzetelnej oceny dopasowania (np. status M艢P, obszar B+R, itp.)")
clarifying_questions: List[str] = Field(description="Maksymalnie 3 najwa偶niejsze pytania doprecyzowuj膮ce do u偶ytkownika (je艣li needs_more_info to True)")
matches: List[GrantMatchResult] = Field(description="Lista dopasowa艅 (je艣li needs_more_info to False)")
class UserAnswer(BaseModel):
question: str
answer: str
class MatchRequest(BaseModel):
project_id: str
user_answers: List[UserAnswer] = []
class SearchRequest(BaseModel):
query: str
filters: dict = {}
@router.post("/search")
async def search_grants_api(request: SearchRequest):
"""
Wyszukuje nabory na podstawie zapytania tekstowego i filtr贸w (u偶ywaj膮c nowego Search Engine).
"""
try:
results = await grant_search_service.search_grants(request.query, request.filters)
return {"status": "ok", "count": len(results), "grants": results}
except Exception as e:
logger.error(f"Error in search_grants_api: {e}")
raise HTTPException(status_code=500, detail="B艂膮d wyszukiwania nabor贸w")
@router.post("/match")
async def match_grants(
request: MatchRequest,
token_data: dict = Depends(verify_token),
db = Depends(get_db)
):
"""
AI-driven zaawansowane dopasowanie program贸w dotacyjnych dla projektu.
Dzia艂a wieloetapowo: dopytuje o szczeg贸艂y lub zwraca list臋 rekomendacji.
"""
clerk_id = token_data.get("sub")
project = db.query(Project).filter(Project.id == request.project_id, Project.clerk_user_id == clerk_id).first()
if not project:
raise HTTPException(status_code=404, detail="Projekt nie istnieje lub brak dost臋pu")
try:
# Pobieramy najnowsze nabory
nabory_resp = await get_nabory(force_refresh=False)
nabory_list = nabory_resp.get("nabory", [])
if not nabory_list:
return {"status": "ok", "project_id": request.project_id, "needs_more_info": False, "clarifying_questions": [], "matches": []}
# 1. Weryfikacja M艢P (GraphRAG / Neo4j)
nip = project.external_context.get("nip", "") if project.external_context else ""
sme_data = None
if nip:
try:
from core.graph_rag.sme_verifier import sme_verifier
declared_status = project.external_context.get("declared_sme_status", "mikro")
sme_data = sme_verifier.verify_sme_status(nip=nip, declared_status=declared_status)
except Exception as e:
logger.warning(f"SME verifier error: {e}")
sme_context = "Brak zweryfikowanego statusu M艢P (GraphRAG nie zosta艂 wykonany)."
if sme_data:
sme_context = (
f"Zadeklarowany NIP: {nip}\n"
f"Wyliczony status z powi膮za艅 (GraphRAG): {sme_data.get('calculated_status')}\n"
f"Czy status poprawny?: {sme_data.get('is_status_valid')}\n"
f"Uzasadnienie: {sme_data.get('reasoning')}"
)
# 2. RAG Retrieval dla odpowiednich nabor贸w
try:
from rag_pipeline.vector_store import get_parent_document_retriever
retriever = get_parent_document_retriever(namespace="grants_guidelines")
except Exception as e:
logger.warning(f"RAG init error: {e}")
retriever = None
rag_context_docs = []
if retriever:
query_text = f"{project.title} {project.description}"
try:
rag_context_docs = retriever.invoke(query_text)
except Exception as e:
logger.warning(f"RAG retrieval error: {e}")
nabory_context = "Brak szczeg贸艂贸w z RAG."
if rag_context_docs:
docs_text = [f"--- DOKUMENT ---\n{doc.page_content}" for doc in rag_context_docs]
nabory_context = "\n".join(docs_text)
else:
simplified_nabory = []
for n in nabory_list:
desc = n.get('description', '')[:300] if n.get('description') else "Brak szczeg贸艂贸w"
src = n.get('source', 'N/A')
legal = n.get('legal_source', 'N/A')
prog = f"{n.get('program', '')} - {n.get('name', 'N/A')}"
simplified_nabory.append(f"- ID: {n.get('id', 'N/A')} | Program: {prog} | 殴r贸d艂o: {src} | Podstawa prawna: {legal} | Cel/Opis: {desc}")
nabory_context = "\n".join(simplified_nabory)
answers_context = "Brak dodatkowych odpowiedzi."
if request.user_answers:
answers_context = "\n".join([f"Q: {qa.question}\nA: {qa.answer}" for qa in request.user_answers])
system_prompt = """Jeste艣 ekspertem ds. funduszy UE i krajowych (PARP, NCBR).
Zgodnie z Zasad膮 1.2 (Prawda i Dok艂adno艣膰) oraz 4.3 (Status M艢P): Zawsze sprawdzaj kwalifikowalno艣膰 rygorystycznie.
Je艣li cokolwiek wymaga weryfikacji, zg艂o艣 to i nie zak艂adaj optymistycznego scenariusza w ciemno.
PROJEKT U呕YTKOWNIKA:
Tytu艂: {title}
Opis: {description}
Warto艣膰: {value}
Dodatkowy kontekst z systemu: {extra_context}
WERYFIKACJA M艢P (GraphRAG):
{sme_context}
ODPOWIEDZI U呕YTKOWNIKA (DOPRECYZOWANIE):
{answers_context}
ZWR脫CONE PRZEZ RAG WYTYCZNE DOTYCZ膭CE NABOR脫W (Regulaminy):
{nabory_context}
INSTRUKCJA:
1. Twoim zadaniem jest zarekomendowanie najbardziej pasuj膮cych program贸w na podstawie dostarczonych WYTYCZNYCH Z RAG lub fallbacku.
2. We藕 pod uwag臋 wynik Weryfikacji M艢P. Je艣li status M艢P jest niezgodny z wymaganiami naboru, odrzu膰 program lub wyra藕nie to zaznacz.
3. TWARDA REGU艁A PKD: Je艣li w 'Dodatkowym kontek艣cie z systemu' widzisz wyekstrahowane kody PKD (np. 62.01.Z), musisz bezwzgl臋dnie dopasowa膰 do nich bran偶臋. Z g贸ry odrzucaj z rekomendacji programy, kt贸re przeznaczone s膮 dla innych bran偶 (np. rolnictwo, produkcja vs us艂ugi IT). Je艣li kody PKD s膮 wielobran偶owe (wskazuj膮 na r贸偶norodne dzia艂alno艣ci), popro艣 u偶ytkownika o wskazanie, kt贸ra z tych dzia艂alno艣ci b臋dzie g艂贸wnym przedmiotem projektu.
4. Obowi膮zkowo uzupe艂nij pole `source` i `legal_basis` na podstawie podanych danych (jako mechanizm anti-hallucination). Ustal r贸wnie偶 `confidence_score` oceniaj膮c stopie艅 dopasowania. Pami臋taj: Przy `confidence_score` (lub po prostu `score`) < 85%, obowi膮zkowo oznacz pole `requires_verification` jako True.
5. Je艣li z powy偶szych informacji NIE WYNIKAJ膭 kluczowe dla nabor贸w dane, ustaw `needs_more_info=True` i wygeneruj do 3 konkretnych pyta艅 w `clarifying_questions`.
6. Je艣li masz wystarczaj膮co du偶o informacji LUB u偶ytkownik ju偶 odpowiedzia艂 na pytania, ustaw `needs_more_info=False` i zwr贸膰 tablic臋 `matches`. Zignoruj ca艂kiem niepasuj膮ce programy.
"""
prompt = PromptTemplate.from_template(system_prompt)
llm = get_llm(task_type="critical")
chain = prompt | llm.with_structured_output(AdvancedMatchResponseData)
result = chain.invoke({
"title": project.title,
"description": project.description or "Brak opisu",
"value": str(project.estimated_value) + " PLN" if project.estimated_value else "Nieznana",
"extra_context": json.dumps(project.external_context, ensure_ascii=False) if project.external_context else "Brak",
"answers_context": answers_context,
"nabory_context": nabory_context,
"sme_context": sme_context
})
if not result.needs_more_info:
for match in result.matches:
if match.score < 85 or match.confidence_score < 85:
match.requires_verification = True
if not result.needs_more_info:
external_context = project.external_context or {}
external_context["ai_matches"] = [m.model_dump() for m in result.matches]
if request.user_answers:
external_context["ai_matches_qa_history"] = [qa.model_dump() for qa in request.user_answers]
project.external_context = external_context
db.commit()
return {
"status": "ok",
"project_id": request.project_id,
"needs_more_info": result.needs_more_info,
"clarifying_questions": result.clarifying_questions,
"matches": [m.model_dump() for m in result.matches]
}
except Exception as e:
logger.error(f"Error in match_grants for project {request.project_id}: {e}", exc_info=True)
return {"status": "error", "detail": f"B艂膮d generowania dopasowa艅 przez AI: {str(e)}", "needs_more_info": False, "clarifying_questions": [], "matches": []}