Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci-auto-readme-generator.yml
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
42 changes: 42 additions & 0 deletions app/src/lib/tools/auto-readme-generator.ts
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",
},
],
};
2 changes: 2 additions & 0 deletions app/src/lib/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CategoryInfo, ToolCategory, ToolDefinition } from "@/types";
import { apiChangeAnalyzer } from "./api-change-analyzer";
import { apiValidator } from "./api-validator";
import { architectureDiagram } from "./architecture-diagram";
import { autoReadmeGeneratorTool } from "./auto-readme-generator";
import { bugReplayer } from "./bug-replayer";
import { captionGenerator } from "./caption-generator";
// --- Core tool definitions ---
Expand Down Expand Up @@ -114,6 +115,7 @@ export const tools: ToolDefinition[] = [
captionGenerator,
seoWriter,
deepResearch, // Tier 2: LangGraph multi-agent Python service
autoReadmeGeneratorTool,
];

// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions app/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface ToolDefinition {
icon: string;
/** Active or placeholder */
status: ToolStatus;
/** Output format hint for frontend rendering (e.g. "streaming-text") */
outputFormat?: string;
Comment thread
ms-shashank marked this conversation as resolved.

// --- Tier config ---
/**
Expand Down
110 changes: 110 additions & 0 deletions services/python-tools/tools/auto-readme-generator/analyzer.py
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"
Comment thread
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"\[.*\]"):
Comment thread
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()
59 changes: 59 additions & 0 deletions services/python-tools/tools/auto-readme-generator/llm_client.py
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,
}

Comment thread
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,
Comment thread
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()
59 changes: 59 additions & 0 deletions services/python-tools/tools/auto-readme-generator/planner.py
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")
Comment thread
ms-shashank marked this conversation as resolved.
return SECTION_PLANS.get(project_type, SECTION_PLANS["other"])
Comment thread
ms-shashank marked this conversation as resolved.
56 changes: 56 additions & 0 deletions services/python-tools/tools/auto-readme-generator/refiner.py
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,
Comment thread
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

Comment thread
ms-shashank marked this conversation as resolved.
for attempt in range(2):
Comment thread
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/, "
Comment thread
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architecture: This file only declares httpx, but Tier 2 tools must run as FastAPI services. Please add fastapi, uvicorn[standard], and pydantic (if not already included by FastAPI) to satisfy the service architecture requirements.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this tool lives in services/python-tools/, it should follow our Tier 2 architecture (FastAPI service in Docker). Please update requirements.txt to include the service dependencies.

And make sure your tool exposes a FastAPI endpoint that the frontend can proxy to through our unified API route. Check other tools in services/python-tools/tools/ for reference.

If your tool is intentionally designed as a standalone script rather than a service, let me know and we can discuss the right approach.

Comment thread
ms-shashank marked this conversation as resolved.
pydantic==2.7.1
Comment thread
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
Loading
Loading