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": []}