GitHub Actions
Deploy financing-starts-now from e5d6456
d979993
Raw
History Blame Contribute Delete
18.2 kB
"""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()