| """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 = { |
| "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} |
|
|
| |
| FIT_WEIGHT, TIMING_WEIGHT, EFFORT_WEIGHT = 0.50, 0.25, 0.25 |
|
|
|
|
| def new_session() -> dict: |
| return {"answers": [], "profile": {}} |
|
|
|
|
| |
|
|
| 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 |
| 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 = profile.get("academic_level") |
| req_academic = grant.get("requires_academic") |
| if academic and req_academic: |
| |
| if req_academic == "phd" and academic in ("no_phd", "phd_student"): |
| return False |
| |
| if req_academic == "postdoc_early" and academic not in ("postdoc_early",): |
| return False |
| |
| if req_academic == "postdoc_senior" and academic in ("no_phd", "phd_student"): |
| return False |
|
|
| |
| age = profile.get("age_range") |
| if age == "30_plus" and grant.get("requires_under_30"): |
| return False |
|
|
| |
| employment = profile.get("employment_status") |
| if employment and grant.get("requires_unemployed"): |
| if employment not in ("unemployed",): |
| return False |
|
|
| |
| 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)] |
|
|
|
|
| |
|
|
| CORE_QUESTIONS = {"entity", "location", "trl", "budget", "sector"} |
|
|
|
|
| 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 |
| for q in QUESTIONS: |
| if q["id"] in answered or q["id"] in skip: |
| continue |
| if q["id"] in core: |
| return q |
| |
| |
| 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 |
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
| |
| |
| |
| 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 |
|
|
|
|
| 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) |
| |
| active = [r for r in out if not _is_expired(r["grant"])] |
| if active: |
| return active |
| return out |
|
|
|
|
| |
|
|
| 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() |
|
|