-
Notifications
You must be signed in to change notification settings - Fork 2
feat: implement issue #23 auto-readme-generator multi-agent pipeline #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6d2037e
bc51bff
2c5fde2
5c00ce8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| name: CI - auto-readme-generator | ||
|
|
||
| on: | ||
| push: | ||
| paths: | ||
| - "services/python-tools/tools/auto-readme-generator/**" | ||
| pull_request: | ||
| paths: | ||
| - "services/python-tools/tools/auto-readme-generator/**" | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: "3.11" | ||
| - name: Install deps | ||
| run: pip install -r services/python-tools/tools/auto-readme-generator/requirements.txt -r services/python-tools/tools/auto-readme-generator/requirements-dev.txt | ||
| - name: Run tests | ||
| run: | | ||
| cd services/python-tools/tools/auto-readme-generator | ||
| pytest tests/ -v |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import type { ToolDefinition } from "@/types"; | ||
|
|
||
| export const autoReadmeGeneratorTool: ToolDefinition = { | ||
| id: "auto-readme-generator", | ||
| name: "Auto README Generator", | ||
| description: "Multi-agent pipeline that generates structured, validated READMEs.", | ||
| category: "documentation", | ||
| icon: "FileText", | ||
| status: "active", | ||
| outputFormat: "streaming-text", | ||
|
|
||
| // Tier 2: runs in the unified Python tool runner | ||
| tier: "tier2", | ||
|
|
||
| requiredFields: ["projectName", "projectDescription"], | ||
| defaultModel: "llama-3.3-70b", | ||
| buildSystemPrompt: () => "", | ||
| buildUserPrompt: ({ projectName, projectDescription, techStack }) => | ||
| JSON.stringify({ projectName, projectDescription, techStack }), | ||
|
|
||
| inputs: [ | ||
| { | ||
| key: "projectName", | ||
| label: "Project Name", | ||
| type: "text", | ||
| placeholder: "e.g. my-fastapi-app", | ||
| }, | ||
| { | ||
| key: "projectDescription", | ||
| label: "Project Description", | ||
| type: "textarea", | ||
| placeholder: "What does your project do? Main features, target users.", | ||
| rows: 5, | ||
| }, | ||
| { | ||
| key: "techStack", | ||
| label: "Tech Stack (optional)", | ||
| type: "text", | ||
| placeholder: "e.g. Python, FastAPI, PostgreSQL, Docker", | ||
| }, | ||
| ], | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import json | ||
| import re | ||
| from typing import Literal | ||
|
|
||
| from pydantic import BaseModel, ValidationError | ||
|
|
||
| from llm_client import call_oxlo_chat | ||
|
|
||
| ANALYZER_MODEL = "deepseek-v3.2" | ||
|
ms-shashank marked this conversation as resolved.
|
||
|
|
||
| DEFAULT_METADATA = { | ||
| "language": "unknown", | ||
| "package_manager": "unknown", | ||
| "framework": "unknown", | ||
| "entry_point": "unknown", | ||
| "project_type": "other", | ||
| } | ||
|
|
||
|
|
||
| class ProjectMetadata(BaseModel): | ||
| language: str = "unknown" | ||
| package_manager: str = "unknown" | ||
| framework: str = "unknown" | ||
| entry_point: str = "unknown" | ||
| project_type: Literal["library", "cli", "web-api", "web-app", "other"] = "other" | ||
|
|
||
|
|
||
| def _sanitize(value: str, max_len: int = 500) -> str: | ||
| if not isinstance(value, str): | ||
| return "" | ||
| return value.strip()[:max_len] | ||
|
|
||
|
|
||
| def _extract_json(text: str) -> str: | ||
| text = text.strip() | ||
| if text.startswith("```"): | ||
| text = re.sub(r"^```[a-zA-Z0-9_-]*", "", text) | ||
| text = re.sub(r"```$", "", text.strip()) | ||
| return text | ||
|
|
||
|
|
||
| def _parse_json(text: str) -> dict: | ||
| try: | ||
| parsed = json.loads(text) | ||
| if isinstance(parsed, dict): | ||
| return parsed | ||
| if isinstance(parsed, list): | ||
| for item in parsed: | ||
| if isinstance(item, dict): | ||
| return item | ||
| except json.JSONDecodeError: | ||
| pass | ||
|
|
||
| for pattern in (r"\{.*\}", r"\[.*\]"): | ||
|
ms-shashank marked this conversation as resolved.
|
||
| match = re.search(pattern, text, re.DOTALL) | ||
| if not match: | ||
| continue | ||
| try: | ||
| parsed = json.loads(match.group(0)) | ||
| except json.JSONDecodeError: | ||
| continue | ||
| if isinstance(parsed, dict): | ||
| return parsed | ||
| if isinstance(parsed, list): | ||
| for item in parsed: | ||
| if isinstance(item, dict): | ||
| return item | ||
|
|
||
| return DEFAULT_METADATA.copy() | ||
|
|
||
|
|
||
| async def analyze_project(name: str, description: str, tech_stack: str) -> dict: | ||
| safe_name = _sanitize(name) | ||
| safe_description = _sanitize(description, max_len=2000) | ||
| safe_tech_stack = _sanitize(tech_stack) | ||
|
|
||
| system_prompt = ( | ||
| "You are a project analyzer. " | ||
| "Respond ONLY with a JSON object. No markdown, no explanation." | ||
| ) | ||
| project_data = json.dumps( | ||
| { | ||
| "name": safe_name, | ||
| "description": safe_description, | ||
| "tech_stack": safe_tech_stack, | ||
| }, | ||
| ensure_ascii=False, | ||
| ) | ||
| user_prompt = ( | ||
| f"Project data (JSON):\n{project_data}\n\n" | ||
| "Return JSON with keys: language, package_manager, framework, " | ||
| "entry_point, project_type (library|cli|web-api|web-app|other)." | ||
| ) | ||
|
|
||
| try: | ||
| raw = await call_oxlo_chat( | ||
| ANALYZER_MODEL, | ||
| system_prompt, | ||
| user_prompt, | ||
| max_tokens=512, | ||
| temperature=0.2, | ||
| ) | ||
|
|
||
| cleaned = _extract_json(raw) | ||
| parsed = _parse_json(cleaned) | ||
| return ProjectMetadata(**parsed).model_dump() | ||
| except ValidationError: | ||
| return DEFAULT_METADATA.copy() | ||
| except Exception: | ||
| return DEFAULT_METADATA.copy() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import os | ||
|
|
||
| import httpx | ||
|
|
||
| OXLO_BASE_URL = os.getenv("OXLO_BASE_URL", "https://api.oxlo.ai/v1") | ||
| OXLO_API_KEY = os.getenv("OXLO_API_KEY", "") | ||
| _CLIENT: httpx.AsyncClient | None = None | ||
|
|
||
|
|
||
| class OxloError(RuntimeError): | ||
| pass | ||
|
|
||
|
|
||
| def _get_client() -> httpx.AsyncClient: | ||
| global _CLIENT | ||
| if _CLIENT is None: | ||
| _CLIENT = httpx.AsyncClient() | ||
| return _CLIENT | ||
|
|
||
|
|
||
| async def call_oxlo_chat( | ||
| model: str, | ||
| system_prompt: str, | ||
| user_prompt: str, | ||
| max_tokens: int = 2048, | ||
| temperature: float = 0.3, | ||
| ) -> str: | ||
| if not OXLO_API_KEY: | ||
| raise OxloError("OXLO_API_KEY not configured") | ||
|
|
||
| payload = { | ||
| "model": model, | ||
| "messages": [ | ||
| {"role": "system", "content": system_prompt}, | ||
| {"role": "user", "content": user_prompt}, | ||
| ], | ||
| "temperature": temperature, | ||
| "max_tokens": max_tokens, | ||
| } | ||
|
|
||
|
ms-shashank marked this conversation as resolved.
|
||
| client = _get_client() | ||
| resp = await client.post( | ||
| f"{OXLO_BASE_URL}/chat/completions", | ||
| headers={"Authorization": f"Bearer {OXLO_API_KEY}"}, | ||
| json=payload, | ||
| timeout=30, | ||
|
ms-shashank marked this conversation as resolved.
|
||
| ) | ||
| resp.raise_for_status() | ||
| data = resp.json() | ||
| choices = data.get("choices") | ||
| if not choices or not isinstance(choices, list): | ||
| raise OxloError(f"Unexpected API response: no choices returned. Response: {data}") | ||
|
|
||
| message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {} | ||
| content = message.get("content") | ||
| if content is None: | ||
| raise OxloError(f"Unexpected API response: content is None. Message: {message}") | ||
|
|
||
| return content.strip() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| SECTION_PLANS = { | ||
| "web-api": [ | ||
| "Title", | ||
| "Badges", | ||
| "Description", | ||
| "Features", | ||
| "Quick Start", | ||
| "Usage", | ||
| "API Reference", | ||
| "Config", | ||
| "Contributing", | ||
| "License", | ||
| ], | ||
| "cli": [ | ||
| "Title", | ||
| "Badges", | ||
| "Description", | ||
| "Features", | ||
| "Installation", | ||
| "Usage", | ||
| "Config", | ||
| "Contributing", | ||
| "License", | ||
| ], | ||
| "library": [ | ||
| "Title", | ||
| "Badges", | ||
| "Description", | ||
| "Installation", | ||
| "Usage", | ||
| "API Reference", | ||
| "Contributing", | ||
| "License", | ||
| ], | ||
| "web-app": [ | ||
| "Title", | ||
| "Badges", | ||
| "Description", | ||
| "Features", | ||
| "Quick Start", | ||
| "Usage", | ||
| "Config", | ||
| "Contributing", | ||
| "License", | ||
| ], | ||
| "other": [ | ||
| "Title", | ||
| "Description", | ||
| "Installation", | ||
| "Usage", | ||
| "Contributing", | ||
| "License", | ||
| ], | ||
| } | ||
|
|
||
|
|
||
| def plan_sections(metadata: dict) -> list: | ||
| project_type = (metadata or {}).get("project_type", "other") | ||
|
ms-shashank marked this conversation as resolved.
|
||
| return SECTION_PLANS.get(project_type, SECTION_PLANS["other"]) | ||
|
ms-shashank marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| from typing import Awaitable, Callable, Optional | ||
|
|
||
| from llm_client import call_oxlo_chat | ||
| from validator import validate_readme | ||
|
|
||
| REFINER_MODEL = "llama-3.3-70b" | ||
|
|
||
|
|
||
| def _sanitize(value: str, max_len: int = 12000) -> str: | ||
| if not isinstance(value, str): | ||
| return "" | ||
| return value.strip()[:max_len] | ||
|
|
||
|
|
||
| async def refine_readme( | ||
| content: str, | ||
| issues: list, | ||
| metadata: dict, | ||
| section_plan: list, | ||
|
ms-shashank marked this conversation as resolved.
|
||
| call_model: Optional[Callable[[str, str, str, int, float], Awaitable[str]]] = None, | ||
| ) -> str: | ||
| if not issues: | ||
| return content | ||
|
|
||
| call_model = call_model or call_oxlo_chat | ||
|
|
||
|
ms-shashank marked this conversation as resolved.
|
||
| for attempt in range(2): | ||
|
ms-shashank marked this conversation as resolved.
|
||
| issue_summary = "\n".join( | ||
| f"- [{item['type']}] {item['detail']}" for item in issues | ||
| ) | ||
| safe_issue_summary = _sanitize(issue_summary, max_len=2000) | ||
| safe_content = _sanitize(content, max_len=20000) | ||
| system_prompt = ( | ||
| "You are a technical writer. Fix the README to resolve the listed issues. " | ||
| "Return the full corrected README only." | ||
| ) | ||
| user_prompt = ( | ||
| f"Issues to fix:\n{safe_issue_summary}\n\n" | ||
| f"README:\n{safe_content}\n\n" | ||
| "Ensure all required sections are present, badges use https://img.shields.io/, " | ||
|
ms-shashank marked this conversation as resolved.
|
||
| "and code fences include a language tag." | ||
| ) | ||
|
|
||
| content = await call_model( | ||
| REFINER_MODEL, | ||
| system_prompt, | ||
| user_prompt, | ||
| 4096, | ||
| 0.2, | ||
| ) | ||
|
|
||
| issues = validate_readme(content, section_plan, metadata) | ||
| if not issues: | ||
| break | ||
|
|
||
| return content | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| pytest==8.3.2 | ||
| pytest-asyncio==0.23.8 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| httpx==0.27.0 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Architecture: This file only declares
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this tool lives in And make sure your tool exposes a FastAPI endpoint that the frontend can proxy to through our unified API route. Check other tools in If your tool is intentionally designed as a standalone script rather than a service, let me know and we can discuss the right approach.
ms-shashank marked this conversation as resolved.
|
||
| pydantic==2.7.1 | ||
|
ms-shashank marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import pathlib | ||
| import sys | ||
|
|
||
| TOOL_DIR = pathlib.Path(__file__).resolve().parents[1] | ||
| if str(TOOL_DIR) not in sys.path: | ||
| sys.path.insert(0, str(TOOL_DIR)) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| [pytest] | ||
| asyncio_mode = auto |
Uh oh!
There was an error while loading. Please reload this page.