From e27258b79efb37ffdc95dc791738241505709524 Mon Sep 17 00:00:00 2001 From: Jaime Sanchiz Perez <27894822+jasanpe@users.noreply.github.com> Date: Thu, 28 May 2026 07:35:54 +0200 Subject: [PATCH] Add open source issue recommender domain --- README.md | 11 + arch__OpenSourceIssueRecommender.py | 15 ++ open_source_issue_domain.py | 317 +++++++++++++++++++++++++ open_source_issue_recommender.py | 161 +++++++++++++ tests/test_open_source_issue_domain.py | 76 ++++++ 5 files changed, 580 insertions(+) create mode 100644 arch__OpenSourceIssueRecommender.py create mode 100644 open_source_issue_domain.py create mode 100644 open_source_issue_recommender.py create mode 100644 tests/test_open_source_issue_domain.py diff --git a/README.md b/README.md index 26cb9cc..ad289ae 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,17 @@ You're done! Access the app at `localhost:8501` in your browser. The recommender system works by loading a set of random video links. Once the user hits the Run button, a video will be shown, and the system will suggest whether it recommends the video or not. The user can then provide feedback using "pain" or "pleasure" signals to guide the recommendation process. Based on this feedback, the system adjusts its responses and suggests another video. This cycle continues, allowing for more accurate and personalized recommendations over time. +### Open-source issue recommender domain + +This repo also includes an offline open-source issue recommender domain that keeps the same AO-compatible 8-bit input shape: + +```bash +python open_source_issue_recommender.py --area frontend --effort small --level new --beginner-friendly --goal portfolio --stack react --stack css +python -m unittest discover -s tests -p "test_open_source_issue_domain.py" +``` + +The local catalog models issue profiles by area, effort, beginner-friendliness, contributor goal, stack, and risk. The CLI and Streamlit entrypoint can rank issues for a contributor profile and use prior liked or disliked issue feedback to shift future recommendations. + ## Contributing diff --git a/arch__OpenSourceIssueRecommender.py b/arch__OpenSourceIssueRecommender.py new file mode 100644 index 0000000..704e5f1 --- /dev/null +++ b/arch__OpenSourceIssueRecommender.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +"""AO architecture for the open-source issue recommender domain.""" + +import ao_arch as ar + + +description = "Open-source issue recommender" + +# area, effort, beginner-friendly bit, contributor goal +arch_i = [3, 2, 1, 2] +arch_z = [10] +arch_c = [] +connector_function = "full_conn" + +arch = ar.Arch(arch_i, arch_z, arch_c, connector_function, description) diff --git a/open_source_issue_domain.py b/open_source_issue_domain.py new file mode 100644 index 0000000..1131ab2 --- /dev/null +++ b/open_source_issue_domain.py @@ -0,0 +1,317 @@ +"""Open-source issue recommendation domain for the AO recommender demo.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + + +AREA_BITS = { + "frontend": [0, 0, 0], + "backend": [0, 0, 1], + "docs": [0, 1, 0], + "testing": [0, 1, 1], + "devops": [1, 0, 0], + "data": [1, 0, 1], + "cli": [1, 1, 0], +} + +EFFORT_BITS = { + "small": [0, 0], + "medium": [0, 1], + "large": [1, 1], +} + +GOAL_BITS = { + "learn": [0, 0], + "quick-win": [0, 1], + "portfolio": [1, 0], + "maintainer-impact": [1, 1], +} + +EFFORT_RANK = {"small": 0, "medium": 1, "large": 2} + + +@dataclass(frozen=True) +class IssueCandidate: + """A local issue-like work item that can be recommended to a contributor.""" + + id: str + title: str + area: str + effort: str + beginner_friendly: bool + goals: tuple[str, ...] + stack: tuple[str, ...] + risk: str + description: str + + +ISSUE_CATALOG: tuple[IssueCandidate, ...] = ( + IssueCandidate( + id="keyboard-navigation-audit", + title="Fix keyboard navigation gaps in the settings panel", + area="frontend", + effort="small", + beginner_friendly=True, + goals=("learn", "quick-win", "portfolio"), + stack=("react", "accessibility", "css"), + risk="low", + description="Audit tab order, focus rings, and escape handling in a compact UI panel.", + ), + IssueCandidate( + id="flaky-date-parser-test", + title="Add regression coverage for locale-sensitive date parsing", + area="testing", + effort="small", + beginner_friendly=True, + goals=("learn", "quick-win"), + stack=("python", "parser", "unittest"), + risk="low", + description="Capture known date edge cases and protect the parser from locale drift.", + ), + IssueCandidate( + id="api-pagination-bounds", + title="Clamp API pagination bounds and document defaults", + area="backend", + effort="small", + beginner_friendly=True, + goals=("quick-win", "maintainer-impact"), + stack=("typescript", "api", "validation"), + risk="low", + description="Reject negative pages, cap page size, and make pagination behavior explicit.", + ), + IssueCandidate( + id="docker-healthcheck", + title="Add Docker healthcheck and startup diagnostics", + area="devops", + effort="medium", + beginner_friendly=False, + goals=("portfolio", "maintainer-impact"), + stack=("docker", "bash", "ci"), + risk="medium", + description="Expose a lightweight health endpoint and wire container health status to it.", + ), + IssueCandidate( + id="docs-install-paths", + title="Clarify install paths for Windows, macOS, and Linux", + area="docs", + effort="small", + beginner_friendly=True, + goals=("learn", "quick-win"), + stack=("markdown", "docs"), + risk="low", + description="Replace ambiguous setup instructions with verified platform-specific paths.", + ), + IssueCandidate( + id="csv-import-memory", + title="Stream large CSV imports without loading the whole file", + area="data", + effort="medium", + beginner_friendly=False, + goals=("portfolio", "maintainer-impact"), + stack=("python", "csv", "performance"), + risk="medium", + description="Process rows incrementally and report malformed rows without aborting the batch.", + ), + IssueCandidate( + id="cli-config-precedence", + title="Make CLI config precedence deterministic", + area="cli", + effort="medium", + beginner_friendly=False, + goals=("portfolio", "maintainer-impact"), + stack=("node", "cli", "config"), + risk="medium", + description="Define and test precedence across flags, environment variables, and config files.", + ), + IssueCandidate( + id="storybook-empty-state", + title="Add empty-state stories for data-heavy components", + area="frontend", + effort="small", + beginner_friendly=True, + goals=("learn", "portfolio", "quick-win"), + stack=("react", "storybook", "css"), + risk="low", + description="Show loading, empty, error, and populated states for reusable dashboard widgets.", + ), + IssueCandidate( + id="scheduler-race-condition", + title="Prevent duplicate background jobs after rapid restarts", + area="backend", + effort="large", + beginner_friendly=False, + goals=("portfolio", "maintainer-impact"), + stack=("python", "scheduler", "database"), + risk="high", + description="Guard job leases with durable ownership so restarts cannot enqueue duplicates.", + ), + IssueCandidate( + id="ci-cache-split", + title="Split CI caches by lockfile and runtime version", + area="devops", + effort="medium", + beginner_friendly=True, + goals=("quick-win", "maintainer-impact"), + stack=("github-actions", "ci", "node"), + risk="low", + description="Avoid stale dependency restores by keying caches on lockfiles and runtime versions.", + ), + IssueCandidate( + id="benchmark-data-fixture", + title="Add a small benchmark fixture for import performance", + area="data", + effort="small", + beginner_friendly=True, + goals=("learn", "quick-win", "portfolio"), + stack=("python", "benchmark", "csv"), + risk="low", + description="Provide deterministic fixture data and a repeatable timing harness for imports.", + ), + IssueCandidate( + id="upgrade-guide-mdx", + title="Write a migration guide for the new component API", + area="docs", + effort="medium", + beginner_friendly=True, + goals=("portfolio", "maintainer-impact"), + stack=("mdx", "docs", "react"), + risk="low", + description="Explain breaking changes with before/after snippets and a checklist for maintainers.", + ), +) + + +def get_issue_by_id(issue_id: str) -> IssueCandidate: + """Return a catalog issue by id.""" + + for issue in ISSUE_CATALOG: + if issue.id == issue_id: + return issue + raise ValueError(f"Unknown issue id: {issue_id}") + + +def encode_issue(issue: IssueCandidate, goal: str = "quick-win") -> list[int]: + """Encode an issue plus contributor goal into the AO eight-bit input shape.""" + + _validate_option("area", issue.area, AREA_BITS) + _validate_option("effort", issue.effort, EFFORT_BITS) + _validate_option("goal", goal, GOAL_BITS) + + beginner_bit = [1 if issue.beginner_friendly else 0] + return AREA_BITS[issue.area] + EFFORT_BITS[issue.effort] + beginner_bit + GOAL_BITS[goal] + + +def rank_issues( + preferred_area: str = "any", + effort_budget: str = "medium", + contributor_level: str = "intermediate", + wants_beginner_friendly: bool = False, + goal: str = "quick-win", + stack: Iterable[str] | None = None, + feedback: Iterable[dict[str, object]] | None = None, + limit: int = 5, +) -> list[dict[str, object]]: + """Rank catalog issues for a contributor profile and optional prior feedback.""" + + if preferred_area != "any": + _validate_option("preferred_area", preferred_area, AREA_BITS) + _validate_option("effort_budget", effort_budget, EFFORT_BITS) + _validate_option("goal", goal, GOAL_BITS) + if contributor_level not in {"new", "intermediate", "experienced"}: + raise ValueError(f"Unknown contributor_level: {contributor_level}") + if limit < 1: + raise ValueError("limit must be at least 1") + + desired_stack = {item.lower() for item in (stack or [])} + feedback_items = list(feedback or []) + budget_rank = EFFORT_RANK[effort_budget] + ranked = [] + + for issue in ISSUE_CATALOG: + score = 0 + if preferred_area == "any": + score += 1 + elif issue.area == preferred_area: + score += 5 + + if EFFORT_RANK[issue.effort] <= budget_rank: + score += 3 + else: + score -= 4 + + if goal in issue.goals: + score += 4 + if issue.risk == "low": + score += 2 + elif issue.risk == "high": + score -= 3 + + if wants_beginner_friendly and issue.beginner_friendly: + score += 3 + if contributor_level == "new" and issue.beginner_friendly: + score += 2 + if contributor_level == "experienced" and issue.effort in {"medium", "large"}: + score += 1 + + score += 2 * len(desired_stack.intersection(issue.stack)) + score += _feedback_score(issue, feedback_items) + + ranked.append( + { + "id": issue.id, + "title": issue.title, + "area": issue.area, + "effort": issue.effort, + "beginner_friendly": issue.beginner_friendly, + "goals": issue.goals, + "stack": issue.stack, + "risk": issue.risk, + "description": issue.description, + "binary_input": encode_issue(issue, goal=goal), + "score": score, + } + ) + + ranked.sort(key=lambda item: (-int(item["score"]), str(item["id"]))) + return ranked[:limit] + + +def format_recommendations(recommendations: Iterable[dict[str, object]]) -> str: + """Format recommendations for CLI output.""" + + lines = [] + for issue in recommendations: + stack = ", ".join(issue["stack"]) + lines.append( + f"{issue['id']} | score {issue['score']} | {issue['area']} | " + f"{issue['effort']} | {stack}\n {issue['description']}" + ) + return "\n".join(lines) + + +def _feedback_score(issue: IssueCandidate, feedback_items: Iterable[dict[str, object]]) -> int: + score = 0 + issue_stack = set(issue.stack) + for item in feedback_items: + issue_id = item.get("issue_id") + liked = bool(item.get("liked", True)) + if not issue_id: + continue + reference = get_issue_by_id(str(issue_id)) + direction = 1 if liked else -1 + if issue.id == reference.id: + score += direction * 5 + if issue.area == reference.area: + score += direction * 3 + if issue.effort == reference.effort: + score += direction + score += direction * len(issue_stack.intersection(reference.stack)) + return score + + +def _validate_option(name: str, value: str, options: dict[str, list[int]]) -> None: + if value not in options: + valid = ", ".join(sorted(options)) + raise ValueError(f"Unknown {name}: {value}. Expected one of: {valid}") diff --git a/open_source_issue_recommender.py b/open_source_issue_recommender.py new file mode 100644 index 0000000..53249d3 --- /dev/null +++ b/open_source_issue_recommender.py @@ -0,0 +1,161 @@ +"""CLI and Streamlit demo for the open-source issue recommender domain.""" + +from __future__ import annotations + +import argparse + +from open_source_issue_domain import format_recommendations, rank_issues + + +def recommend( + preferred_area: str = "any", + effort_budget: str = "medium", + contributor_level: str = "intermediate", + wants_beginner_friendly: bool = False, + goal: str = "quick-win", + stack: list[str] | None = None, + feedback: list[dict[str, object]] | None = None, + limit: int = 5, +) -> list[dict[str, object]]: + """Return ranked open-source issues through the deterministic fallback recommender.""" + + return rank_issues( + preferred_area=preferred_area, + effort_budget=effort_budget, + contributor_level=contributor_level, + wants_beginner_friendly=wants_beginner_friendly, + goal=goal, + stack=stack, + feedback=feedback, + limit=limit, + ) + + +def run_cli(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="Recommend open-source issues for a contributor profile.") + parser.add_argument( + "--area", + default="any", + choices=["any", "frontend", "backend", "docs", "testing", "devops", "data", "cli"], + ) + parser.add_argument("--effort", default="medium", choices=["small", "medium", "large"]) + parser.add_argument("--level", default="intermediate", choices=["new", "intermediate", "experienced"]) + parser.add_argument("--beginner-friendly", action="store_true") + parser.add_argument( + "--goal", + default="quick-win", + choices=["learn", "quick-win", "portfolio", "maintainer-impact"], + ) + parser.add_argument("--stack", action="append", default=[]) + parser.add_argument("--liked-issue", action="append", default=[]) + parser.add_argument("--disliked-issue", action="append", default=[]) + parser.add_argument("--limit", type=int, default=5) + args = parser.parse_args(argv) + + feedback = [{"issue_id": issue_id, "liked": True} for issue_id in args.liked_issue] + feedback.extend({"issue_id": issue_id, "liked": False} for issue_id in args.disliked_issue) + + recommendations = recommend( + preferred_area=args.area, + effort_budget=args.effort, + contributor_level=args.level, + wants_beginner_friendly=args.beginner_friendly, + goal=args.goal, + stack=args.stack, + feedback=feedback, + limit=args.limit, + ) + print(format_recommendations(recommendations)) + + +def run_streamlit() -> None: + import streamlit as st + + st.title("Open-Source Issue Recommender") + st.caption("An offline AO-compatible domain for matching contributors with issue profiles.") + + preferred_area = st.selectbox( + "Preferred area", + ["any", "frontend", "backend", "docs", "testing", "devops", "data", "cli"], + ) + effort_budget = st.radio("Effort budget", ["small", "medium", "large"], index=1, horizontal=True) + contributor_level = st.radio( + "Contributor level", + ["new", "intermediate", "experienced"], + index=1, + horizontal=True, + ) + wants_beginner_friendly = st.checkbox("Prefer beginner-friendly issues") + goal = st.selectbox("Contribution goal", ["learn", "quick-win", "portfolio", "maintainer-impact"]) + stack = st.multiselect( + "Known stack", + [ + "accessibility", + "api", + "bash", + "benchmark", + "ci", + "css", + "csv", + "database", + "docker", + "docs", + "github-actions", + "mdx", + "node", + "python", + "react", + "storybook", + "typescript", + ], + ) + liked_issues = st.multiselect( + "Recommend more like", + [ + "keyboard-navigation-audit", + "flaky-date-parser-test", + "api-pagination-bounds", + "docker-healthcheck", + "docs-install-paths", + "csv-import-memory", + "cli-config-precedence", + "storybook-empty-state", + "scheduler-race-condition", + "ci-cache-split", + "benchmark-data-fixture", + "upgrade-guide-mdx", + ], + ) + + feedback = [{"issue_id": issue_id, "liked": True} for issue_id in liked_issues] + recommendations = recommend( + preferred_area=preferred_area, + effort_budget=effort_budget, + contributor_level=contributor_level, + wants_beginner_friendly=wants_beginner_friendly, + goal=goal, + stack=stack, + feedback=feedback, + ) + + for issue in recommendations: + st.subheader(f"{issue['title']} - score {issue['score']}") + st.write(issue["description"]) + st.write("Area:", issue["area"], "| Effort:", issue["effort"], "| Risk:", issue["risk"]) + st.write("AO input:", issue["binary_input"]) + st.write("Stack:", ", ".join(issue["stack"])) + + +def _running_in_streamlit() -> bool: + try: + from streamlit.runtime.scriptrunner import get_script_run_ctx + except Exception: + return False + return get_script_run_ctx() is not None + + +if __name__ == "__main__": + if _running_in_streamlit(): + run_streamlit() + else: + run_cli() diff --git a/tests/test_open_source_issue_domain.py b/tests/test_open_source_issue_domain.py new file mode 100644 index 0000000..669e496 --- /dev/null +++ b/tests/test_open_source_issue_domain.py @@ -0,0 +1,76 @@ +import unittest + +from open_source_issue_domain import ( + encode_issue, + get_issue_by_id, + rank_issues, +) + + +class OpenSourceIssueDomainTest(unittest.TestCase): + def test_encode_issue_returns_ao_compatible_eight_bit_input(self): + issue = get_issue_by_id("api-pagination-bounds") + + self.assertEqual( + encode_issue(issue, goal="maintainer-impact"), + [0, 0, 1, 0, 0, 1, 1, 1], + ) + + def test_rank_issues_matches_new_frontend_contributor(self): + recommendations = rank_issues( + preferred_area="frontend", + effort_budget="small", + contributor_level="new", + wants_beginner_friendly=True, + goal="portfolio", + stack=["react", "css"], + limit=3, + ) + + self.assertEqual(recommendations[0]["id"], "keyboard-navigation-audit") + self.assertEqual(recommendations[0]["area"], "frontend") + self.assertGreaterEqual(recommendations[0]["score"], recommendations[1]["score"]) + + def test_rank_issues_matches_devops_maintainer_goal(self): + recommendations = rank_issues( + preferred_area="devops", + effort_budget="medium", + contributor_level="experienced", + goal="maintainer-impact", + stack=["docker", "ci"], + limit=3, + ) + + self.assertEqual(recommendations[0]["id"], "ci-cache-split") + self.assertEqual(recommendations[0]["area"], "devops") + + def test_feedback_can_change_recommendation_order(self): + baseline = rank_issues( + preferred_area="any", + effort_budget="medium", + contributor_level="intermediate", + goal="quick-win", + stack=["python"], + limit=2, + ) + + with_feedback = rank_issues( + preferred_area="any", + effort_budget="medium", + contributor_level="intermediate", + goal="quick-win", + stack=["python"], + feedback=[{"issue_id": "csv-import-memory", "liked": True}], + limit=2, + ) + + self.assertNotEqual(baseline[0]["id"], "csv-import-memory") + self.assertEqual(with_feedback[0]["id"], "csv-import-memory") + + def test_invalid_area_is_rejected(self): + with self.assertRaises(ValueError): + rank_issues(preferred_area="mobile") + + +if __name__ == "__main__": + unittest.main()