"""Akinator engine — adaptive question flow + grant scoring. Implements docs/akinator-flow/QUESTION_TREE.md and SCORING_LOGIC.md: each answer narrows the pool of 10 grants (hard elimination), questions that can no longer discriminate are skipped, and the survivors are scored on fit (50%) / timing (25%) / effort (25%) into a match percentage. Session state is a plain dict so it round-trips through gr.State: {"answers": [(qid, key), ...], "profile": {...}} """ from __future__ import annotations from grants import GRANTS, TODAY # TRL bands shown to the user (numbers hidden, per QUESTION_TREE.md) TRL_BANDS = { "idea": (1, 3), "prototype": (4, 5), "demo": (6, 8), "market": (9, 9), } QUESTIONS = [ { "id": "entity", "ask": "Who seeks the gold?", "sub": "What best describes your situation?", "options": [ ("company", "🏢", "I run a registered company (SME / startup)"), ("research", "🎓", "I'm at a university or research institute"), ("homeowner", "🏠", "I'm a private person (home, EV, energy savings)"), ("individual", "👤", "I'm an individual who wants to start a business"), ("exploring", "🧭", "Not sure yet — I'm exploring my options"), ], }, { "id": "location", "ask": "Where does your story unfold?", "sub": "EU programmes mostly require EU or associated-country presence.", "options": [ ("eu", "🇪🇺", "In an EU member state"), ("associated", "🤝", "In a Horizon-Europe associated country"), ("relocating", "✈️", "Outside the EU — but willing to relocate or register there"), ("outside", "🌍", "Outside the EU, and staying there"), ], }, { "id": "trl", "ask": "How far has your magic travelled?", "sub": "Be honest — each stage opens different doors.", "options": [ ("idea", "💡", "Just an idea or early research"), ("prototype", "🔧", "A working prototype, tested in the lab"), ("demo", "🚀", "A demo or pilot, validated in real conditions"), ("market", "📦", "Already on the market, selling to customers"), ], }, { "id": "budget", "ask": "How much treasure do you need?", "sub": "Rough order of magnitude is enough.", "options": [ ("small", "🪙", "Up to €60,000 — a small project or planning"), ("medium", "💰", "€60,000 – €500,000 — development and validation"), ("large", "👑", "More than €500,000 — scale-up or major R&D"), ("unsure", "🤷", "I honestly don't know yet"), ], }, { "id": "sector", "ask": "What is the nature of your craft?", "sub": "The main focus of your innovation.", "options": [ ("ai", "🤖", "AI / digital technology / software"), ("green", "🌱", "Green tech / clean energy / sustainability"), ("health", "🏥", "Healthcare / biotech"), ("manufacturing", "🏭", "Manufacturing / industrial"), ("creative", "🎨", "Creative / cultural industries"), ("other", "📦", "Something else entirely"), ], }, { "id": "prior_eu", "ask": "Has EU gold touched your work before?", "sub": "Prior Horizon / ERC / Pathfinder funding opens one special door.", "options": [ ("yes", "✅", "Yes — results from a prior EU-funded project"), ("no", "❌", "No, never"), ("unsure", "🤔", "I'm not sure"), ], }, { "id": "consortium", "ask": "Would you join forces across borders?", "sub": "Some programmes require a partner in another country.", "options": [ ("yes", "🤝", "Yes — I already have contacts abroad"), ("maybe", "🔍", "Maybe, with help finding partners"), ("no", "🚶", "No — I want to apply alone"), ], }, { "id": "equity", "ask": "Would you trade shares for treasure?", "sub": "Some programmes blend grants with equity investment.", "options": [ ("yes", "📈", "Yes — open to equity for larger funding"), ("no", "🛡️", "No — grants only, I keep my shares"), ("depends", "⚖️", "It depends on the terms"), ], }, { "id": "academic_level", "ask": "What is your academic standing?", "sub": "Some research grants require a doctoral degree.", "options": [ ("postdoc_early", "🎓", "I have a PhD (received within the last 7 years)"), ("postdoc_senior", "🏛️", "I have a PhD (more than 7 years ago)"), ("phd_student", "📚", "I'm currently doing a PhD"), ("no_phd", "🙅", "I don't have a PhD"), ], }, { "id": "age_range", "ask": "How old are you?", "sub": "A few programmes are specifically for young people.", "options": [ ("under_30", "🧑", "Under 30"), ("30_plus", "🧑‍💼", "30 or older"), ], }, { "id": "employment_status", "ask": "What is your employment situation?", "sub": "Some programmes specifically support the unemployed.", "options": [ ("unemployed", "📋", "Currently unemployed or seeking work"), ("employed", "💼", "Employed (full-time, part-time, or freelance)"), ("student", "🎒", "Student"), ], }, { "id": "woman_led", "ask": "Is your company led by a woman?", "sub": "One programme specifically supports women-led deep-tech startups.", "options": [ ("yes", "👩‍💼", "Yes — CEO, founder, or CTO is a woman"), ("no", "➡️", "No"), ], }, ] QUESTIONS_BY_ID = {q["id"]: q for q in QUESTIONS} # Effort scores from SCORING_LOGIC.md live on the grant dicts ("effort"). FIT_WEIGHT, TIMING_WEIGHT, EFFORT_WEIGHT = 0.50, 0.25, 0.25 def new_session() -> dict: return {"answers": [], "profile": {}} # ── elimination ────────────────────────────────────────────────────────────── def grant_alive(grant: dict, profile: dict) -> bool: """Hard eligibility gate per the Grant Elimination Matrix.""" entity = profile.get("entity") if entity and entity != "exploring" and entity not in grant["entities"]: return False loc = profile.get("location") if loc == "outside": return False # no EU presence, not relocating — nothing fits if loc and loc not in grant["locations"]: return False trl = profile.get("trl") if trl: lo, hi = TRL_BANDS[trl] if hi < grant["trl_min"] or lo > grant["trl_max"]: return False budget = profile.get("budget") if budget == "small" and grant["min_amount"] > 60_000: return False if budget == "medium" and (grant["max_amount"] < 60_000 or grant["min_amount"] > 500_000): return False if budget == "large" and grant["max_amount"] < 500_000: return False sector = profile.get("sector") if sector and "any" not in grant["sectors"]: wanted = {sector, "digital"} if sector == "ai" else {sector} if not (wanted & grant["sectors"]): return False if profile.get("consortium") == "no" and grant["requires_consortium"]: return False if profile.get("prior_eu") == "no" and grant["requires_prior_eu"]: return False # Academic level filter academic = profile.get("academic_level") req_academic = grant.get("requires_academic") if academic and req_academic: # "phd" = must have completed a PhD (postdoc_early or postdoc_senior pass) if req_academic == "phd" and academic in ("no_phd", "phd_student"): return False # "postdoc_early" = PhD within last 7 years (only postdoc_early passes) if req_academic == "postdoc_early" and academic not in ("postdoc_early",): return False # "postdoc_senior" = PhD 7+ years ago (postdoc_early OR postdoc_senior pass) if req_academic == "postdoc_senior" and academic in ("no_phd", "phd_student"): return False # Age filter age = profile.get("age_range") if age == "30_plus" and grant.get("requires_under_30"): return False # Employment status filter employment = profile.get("employment_status") if employment and grant.get("requires_unemployed"): if employment not in ("unemployed",): return False # Woman-led filter if profile.get("woman_led") == "no" and grant.get("requires_woman_led"): return False return True def remaining_grants(profile: dict) -> list[dict]: return [g for g in GRANTS if grant_alive(g, profile)] # ── question selection ─────────────────────────────────────────────────────── CORE_QUESTIONS = {"entity", "location", "trl", "budget", "sector"} # always asked def _core_for_profile(profile: dict) -> set[str]: """Homeowners skip TRL and sector (all personal green finance).""" if profile.get("entity") == "homeowner": return CORE_QUESTIONS - {"trl", "sector"} return CORE_QUESTIONS def _question_discriminates(q: dict, profile: dict, remaining: list[dict]) -> bool: """True if at least one answer to q would eliminate at least one grant.""" for key, _icon, _label in q["options"]: trial = dict(profile, **{q["id"]: key}) if len([g for g in remaining if grant_alive(g, trial)]) < len(remaining): return True return False def next_question(session: dict) -> dict | None: profile = session["profile"] remaining = remaining_grants(profile) answered = {qid for qid, _ in session["answers"]} if not remaining: return None core = _core_for_profile(profile) core_done = core <= answered skip = CORE_QUESTIONS - core # questions excluded for this entity type for q in QUESTIONS: if q["id"] in answered or q["id"] in skip: continue if q["id"] in core: return q # conditional questions: only if they still matter, and only while the # pool needs narrowing (Akinator stops once it is confident) if len(remaining) <= 2 and core_done: return None if _question_discriminates(q, profile, remaining): return q if q["id"] == "equity" and any(g["has_equity"] for g in remaining): return q # doesn't eliminate, but changes the recommendation return None def apply_answer(session: dict, qid: str, key: str) -> dict: session["answers"].append((qid, key)) session["profile"][qid] = key return session def is_finished(session: dict) -> bool: return next_question(session) is None # ── scoring (SCORING_LOGIC.md) ─────────────────────────────────────────────── def _fit(grant: dict, p: dict) -> int: score = 0 sector = p.get("sector") if sector and (sector in grant["bonus_sectors"] or ("any" not in grant["sectors"] and sector in grant["sectors"])): score += 30 elif "any" in grant["sectors"]: score += 15 budget = p.get("budget") mids = {"small": 40_000, "medium": 250_000, "large": 1_200_000} need = mids.get(budget) if need is None: score += 15 elif grant["min_amount"] <= need <= grant["max_amount"]: score += 25 elif need <= grant["max_amount"]: score += 15 trl = p.get("trl") if trl: lo, hi = TRL_BANDS[trl] user_mid = (lo + hi) / 2 grant_mid = (grant["trl_min"] + grant["trl_max"]) / 2 score += max(0, round(20 - abs(user_mid - grant_mid) * 5)) else: score += 10 consortium = p.get("consortium") if grant["requires_consortium"]: score += 15 if consortium == "yes" else (8 if consortium == "maybe" else 0) else: score += 15 equity = p.get("equity") if grant["has_equity"]: score += 10 if equity == "yes" else (5 if equity == "depends" else 2) else: score += 10 # Non-grant instruments (debt, vouchers, exchanges) are a different kind of # money — they should support, not outrank, the actual grants # (SCORING_LOGIC.md example: InvestEU fit 45 for a grant-seeking scale-up). if grant["instrument"] != "grant": score -= 30 return max(0, min(score, 100)) def _days_score(days: int) -> int: if days < 0: return 0 if days < 14: return 20 if days < 30: return 50 if days < 90: return 80 if days < 180: return 95 return 100 def _timing(grant: dict) -> int: if grant["rolling"]: return 90 if not grant["deadlines"]: return 70 best = max(_days_score((d - TODAY).days) for d in grant["deadlines"]) return best if best > 0 else 5 # all cycles passed — heavily penalise def near_miss_grants(profile: dict) -> tuple[list[dict], str | None]: """When elimination empties the pool, relax soft constraints one at a time and return (ranked near-misses, the constraint that was relaxed).""" for relax in ("budget", "trl", "sector", "consortium"): if relax in profile: trial = {k: v for k, v in profile.items() if k != relax} if remaining_grants(trial): return score_grants(trial), relax return [], None def _is_expired(grant: dict) -> bool: """True if the grant has fixed deadlines and ALL have passed.""" if grant["rolling"] or not grant.get("deadlines"): return False return all(d < TODAY for d in grant["deadlines"]) def score_grants(profile: dict) -> list[dict]: """Ranked survivors with match % and per-dimension breakdown.""" out = [] for g in remaining_grants(profile): fit, timing, effort = _fit(g, profile), _timing(g), g["effort"] total = round(fit * FIT_WEIGHT + timing * TIMING_WEIGHT + effort * EFFORT_WEIGHT, 1) out.append({"grant": g, "score": total, "fit": fit, "timing": timing, "effort": effort}) out.sort(key=lambda r: r["score"], reverse=True) # Hide expired grants if there are active alternatives active = [r for r in out if not _is_expired(r["grant"])] if active: return active return out # ── human-readable profile (for the AI prompt and the dossier) ────────────── ANSWER_PHRASES = { ("entity", "company"): "a registered company (SME/startup)", ("entity", "research"): "a university or research institute", ("entity", "homeowner"): "a private homeowner seeking personal green finance", ("entity", "individual"): "an individual planning to start a business", ("entity", "exploring"): "exploring options (not yet decided on entity type)", ("location", "eu"): "based in an EU member state", ("location", "associated"): "based in a Horizon-Europe associated country", ("location", "relocating"): "outside the EU but willing to relocate/register there", ("location", "outside"): "outside the EU with no plans to relocate", ("trl", "idea"): "at idea/early-research stage (TRL 1-3)", ("trl", "prototype"): "with a lab-tested prototype (TRL 4-5)", ("trl", "demo"): "with a validated demo/pilot (TRL 6-8)", ("trl", "market"): "with a product already on the market (TRL 9)", ("budget", "small"): "needing up to €60K", ("budget", "medium"): "needing €60K-€500K", ("budget", "large"): "needing more than €500K", ("budget", "unsure"): "with funding needs still unclear", ("sector", "ai"): "working in AI/digital technology", ("sector", "green"): "working in green/clean technology", ("sector", "health"): "working in healthcare/biotech", ("sector", "manufacturing"): "working in manufacturing/industry", ("sector", "creative"): "working in the creative/cultural sector", ("sector", "other"): "working in another sector", ("prior_eu", "yes"): "with results from prior EU-funded research", ("prior_eu", "no"): "without prior EU funding", ("prior_eu", "unsure"): "unsure about prior EU funding", ("consortium", "yes"): "able to partner across borders", ("consortium", "maybe"): "open to cross-border partners with help", ("consortium", "no"): "preferring to apply alone", ("equity", "yes"): "open to equity investment", ("equity", "no"): "wanting non-dilutive funding only", ("equity", "depends"): "equity-flexible depending on terms", ("academic_level", "postdoc_early"): "with a recent PhD (0-7 years)", ("academic_level", "postdoc_senior"): "with a PhD (7+ years ago)", ("academic_level", "phd_student"): "currently doing a PhD", ("academic_level", "no_phd"): "without a doctoral degree", ("age_range", "under_30"): "under 30 years old", ("age_range", "30_plus"): "30 or older", ("employment_status", "unemployed"): "currently unemployed", ("employment_status", "employed"): "currently employed", ("employment_status", "student"): "a student", ("woman_led", "yes"): "company led by a woman", ("woman_led", "no"): "not specifically woman-led", } def profile_sentence(session: dict) -> str: bits = [ANSWER_PHRASES.get((qid, key), f"{qid}={key}") for qid, key in session["answers"]] return "; ".join(bits) def path_key(session: dict) -> str: """Deterministic cache key for this answer path (CACHING_STRATEGY.md).""" import hashlib path = "|".join(f"{q}:{a}" for q, a in sorted(session["answers"])) return hashlib.sha256(path.encode()).hexdigest()