diff --git a/.env.example b/.env.example index d662880..afb2319 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ # ----------------------------------------------------------------------------- # Server # ----------------------------------------------------------------------------- -OWLSCOPE_HOST=0.0.0.0 -OWLSCOPE_PORT=8000 +OWLSCOPE_HOST=127.0.0.1 +OWLSCOPE_PORT=8010 OWLSCOPE_ENV=development OWLSCOPE_DEBUG=true OWLSCOPE_SECRET_KEY=change-me-to-a-random-secret-key @@ -70,3 +70,10 @@ PYPI_API_URL=https://pypi.org/pypi # ----------------------------------------------------------------------------- RATE_LIMIT_FREE=100 RATE_LIMIT_PRO=10000 + +# ---------------------------------------------------------------------------- +# Release Checks +# ---------------------------------------------------------------------------- +# Set to 1 when running scripts/release_check.sh and you want integration test +# included in the same pass. +OWLSCOPE_RELEASE_CHECK_INTEGRATION=0 diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml new file mode 100644 index 0000000..c00c0d2 --- /dev/null +++ b/.github/workflows/release-preflight.yml @@ -0,0 +1,85 @@ +name: Release Preflight + +on: + workflow_dispatch: + push: + branches: [dev] + +jobs: + preflight: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: owlscope + POSTGRES_PASSWORD: owlscope + POSTGRES_DB: owlscope + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U owlscope -d owlscope" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql+asyncpg://owlscope:owlscope@localhost:5432/owlscope + REDIS_URL: redis://localhost:6379/0 + DEBUG: "false" + OWLSCOPE_RELEASE_CHECK_INTEGRATION: "1" + OWLSCOPE_RUN_INTEGRATION: "1" + OWLSCOPE_API_HOST: 127.0.0.1 + OWLSCOPE_API_PORT: 8010 + SECRET_KEY: ci-release-preflight-secret + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: web/package-lock.json + + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Install web dependencies + run: npm ci + working-directory: web + + - name: Prepare schema + run: | + python - <<'PY' + import asyncio + from src.models.database import Base + from src.models.db import engine + + async def main() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await engine.dispose() + + asyncio.run(main()) + print("schema-ready") + PY + + - name: Run release preflight script + run: bash scripts/release_check.sh diff --git a/README.md b/README.md index 1332f34..397c9a3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,14 @@ ```

+

+ Python + FastAPI + Next.js + PostgreSQL + Redis + Qdrant +

Open-source intelligence engine for discovering, evaluating, and adopting open-source libraries, frameworks, and agent skills.

@@ -99,7 +107,7 @@ docker compose up -d Important: Owlscope does not provide shared AI provider keys. You must supply your own API keys for LLM-powered features. -The Web App will be available at `http://localhost:3000` and the API at `http://localhost:8000`. +The Web App will be available at `http://127.0.0.1:3100` and the API at `http://127.0.0.1:8010`. ### Local Development @@ -121,7 +129,7 @@ DEEPSEEK_API_KEY=your_deepseek_key EOF # Start the API server -uvicorn src.api.main:app --reload --port 8000 +uvicorn src.api.main:app --reload --host 127.0.0.1 --port 8010 ``` #### Web App (Next.js) @@ -140,6 +148,24 @@ pip install -e ".[cli]" # Search for projects owlscope search "Python async HTTP client with HTTP/2" + +# Ops preflight (local-first) +owlscope ops preflight + +# Ops deploy (local direct mode, docker as fallback) +owlscope ops deploy --mode local + +# Include frontend startup and checks +owlscope ops deploy --mode local --with-web + +# Run checks without leaving background processes +owlscope ops deploy --mode local --with-web --no-detached + +# Ops deploy via docker explicitly +owlscope ops deploy --mode docker + +# Stop processes started by CLI deploy +owlscope ops stop ``` ## Usage Guidelines @@ -170,37 +196,103 @@ Notes: - The integration suite is excluded from default test runs. - CI always runs this test in a dedicated job with PostgreSQL + Redis services. +## Release Preflight + +Use these commands before deployment: + +```bash +# Validate production environment vars +python scripts/validate_env.py --mode prod + +# One-command release validation (tests + build + smoke) +bash scripts/release_check.sh + +# Include black-box integration test in the same run +OWLSCOPE_RELEASE_CHECK_INTEGRATION=1 bash scripts/release_check.sh +``` + +Production-readiness docs: +- Deployment runbook: `docs/deployment.md` +- Migration/rollback: `docs/migrations.md` +- Smoke checklist: `docs/smoke-test.md` +- Release checklist: `docs/release-checklist.md` + +Production launch commands: + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +bash scripts/post_deploy_check.sh +``` + +CLI-first alternative: + +```bash +owlscope ops deploy --mode local +# if needed: owlscope ops deploy --mode docker +# include web checks: owlscope ops check --with-web +# stop local managed processes: owlscope ops stop +``` + ## API Reference ### REST API ```bash # Search -curl -X POST http://localhost:8000/api/v1/search \ +curl -X POST http://127.0.0.1:8010/api/v1/search \ -H "Content-Type: application/json" \ -d '{"query": "lightweight Python web framework"}' # Evaluate a project -curl http://localhost:8000/api/v1/evaluate/github:library:encode/httpx +curl http://127.0.0.1:8010/api/v1/evaluate/github:library:encode/httpx # Compare projects -curl -X POST http://localhost:8000/api/v1/compare \ +curl -X POST http://127.0.0.1:8010/api/v1/compare \ -H "Content-Type: application/json" \ -d '{"projects": ["github:library:fastapi/fastapi", "github:library:pallets/flask", "github:library:django/django"]}' # Assess whether an idea is already implemented -curl -X POST http://localhost:8000/api/v1/idea/assess \ +curl -X POST http://127.0.0.1:8010/api/v1/idea/assess \ + -H "Content-Type: application/json" \ + -d '{"idea": "AI coding workflow assistant for startup teams", "product_doc": "Need repo indexing, recommendation, and integration guidance"}' + +# Export assessment report as Markdown +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/export?format=markdown" \ -H "Content-Type: application/json" \ -d '{"idea": "AI coding workflow assistant for startup teams", "product_doc": "Need repo indexing, recommendation, and integration guidance"}' +# Export assessment report as JSON envelope +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/export?format=json" \ + -H "Content-Type: application/json" \ + -d '{"idea": "AI coding workflow assistant for startup teams", "product_doc": "Need repo indexing, recommendation, and integration guidance"}' + +# Batch assess multiple ideas +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/batch" \ + -H "Content-Type: application/json" \ + -d '{"items":[{"idea":"Open-source API mocking tool","product_doc":"Need scenario replay"},{"idea":"PR review assistant for OSS maintainers","product_doc":"Need triage automation"}],"limit":6,"max_concurrency":2,"per_item_timeout_seconds":30}' + +# Export batch assessment report as Markdown +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/batch/export?format=markdown" \ + -H "Content-Type: application/json" \ + -d '{"items":[{"idea":"Open-source API mocking tool","product_doc":"Need scenario replay"},{"idea":"PR review assistant for OSS maintainers","product_doc":"Need triage automation"}],"limit":6,"max_concurrency":2,"per_item_timeout_seconds":30}' + +# Export batch assessment report as JSON envelope +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/batch/export?format=json" \ + -H "Content-Type: application/json" \ + -d '{"items":[{"idea":"Open-source API mocking tool","product_doc":"Need scenario replay"},{"idea":"PR review assistant for OSS maintainers","product_doc":"Need triage automation"}],"limit":6,"max_concurrency":2,"per_item_timeout_seconds":30}' + # Response includes: # - verdict + existing_project_probability # - action_recommendation (build|fork|integrate) + action_rationale # - decision_signals for explainability # - similar_projects with evidence_snippets +# - export endpoint supports markdown/json report output +# - batch endpoint returns per-idea results + verdict counts +# - batch supports max_concurrency and per_item_timeout_seconds +# - batch export endpoint supports markdown/json report output ``` -Full API documentation available at `http://localhost:8000/docs` (Swagger UI) or `http://localhost:8000/redoc` (ReDoc). +Full API documentation available at `http://127.0.0.1:8010/docs` (Swagger UI) or `http://127.0.0.1:8010/redoc` (ReDoc). ### MCP Protocol diff --git a/README.zh-CN.md b/README.zh-CN.md index e53bb62..7e675bd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,6 +13,14 @@ ```

+

+ Python + FastAPI + Next.js + PostgreSQL + Redis + Qdrant +

开源情报引擎:帮助开发者与 AI Agent 检索、评估、推荐开源项目与 Agent Skills,并在立项阶段避免重复造轮子。

@@ -74,8 +82,8 @@ docker compose up -d ``` 启动后: -- Web: http://localhost:3000 -- API: http://localhost:8000 +- Web: http://127.0.0.1:3100 +- API: http://127.0.0.1:8010 重要说明:Owlscope 不提供共享 AI Key。所有 LLM 功能都需要用户自行配置 API Key。 @@ -96,7 +104,7 @@ DEEPSEEK_API_KEY=your_deepseek_key # ANTHROPIC_API_KEY=your_anthropic_key EOF -uvicorn src.api.main:app --reload --port 8000 +uvicorn src.api.main:app --reload --host 127.0.0.1 --port 8010 ``` 前端: @@ -106,6 +114,29 @@ npm install npm run dev ``` +CLI: +```bash +pip install -e ".[cli]" + +# 发布前预检 +owlscope ops preflight + +# 本地直跑优先(失败可自动回退 Docker) +owlscope ops deploy --mode local + +# 包含前端启动与校验 +owlscope ops deploy --mode local --with-web + +# 校验完成后自动回收本次启动进程 +owlscope ops deploy --mode local --with-web --no-detached + +# 显式走 Docker +owlscope ops deploy --mode docker + +# 停止 CLI 启动的本地进程 +owlscope ops stop +``` + ## 使用指南 - BYOK(自带 Key):使用 LLM 功能前,必须在 `.env` 配置你自己的 API Key。 @@ -134,18 +165,80 @@ pytest -q -m integration tests/integration/test_compare_blackbox.py - 默认测试不会执行 integration 套件。 - CI 会在独立 Job 中(PostgreSQL + Redis)强制执行该测试。 +## 发布前预检 + +部署前建议执行: + +```bash +# 校验生产环境变量 +python scripts/validate_env.py --mode prod + +# 一键发布前校验(测试 + 构建 + 冒烟) +bash scripts/release_check.sh + +# 在同一流程中包含 integration 黑盒测试 +OWLSCOPE_RELEASE_CHECK_INTEGRATION=1 bash scripts/release_check.sh +``` + +上线文档: +- 部署 Runbook:`docs/deployment.md` +- 迁移/回滚:`docs/migrations.md` +- 冒烟检查:`docs/smoke-test.md` +- 发布清单:`docs/release-checklist.md` + +生产部署命令: + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +bash scripts/post_deploy_check.sh +``` + +CLI 方式(优先本地直跑): + +```bash +owlscope ops deploy --mode local +# 备选:owlscope ops deploy --mode docker +# 包含前端检查:owlscope ops check --with-web +# 停止 CLI 托管进程:owlscope ops stop +``` + ## API 参考 ```bash # 语义搜索 -curl -X POST http://localhost:8000/api/v1/search \ +curl -X POST http://127.0.0.1:8010/api/v1/search \ -H "Content-Type: application/json" \ -d '{"query": "lightweight Python web framework"}' # 想法评估(是否已有开源实现) -curl -X POST http://localhost:8000/api/v1/idea/assess \ +curl -X POST http://127.0.0.1:8010/api/v1/idea/assess \ + -H "Content-Type: application/json" \ + -d '{"idea": "AI coding workflow assistant", "product_doc": "Need repo indexing and recommendation"}' + +# 导出 Markdown 报告 +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/export?format=markdown" \ -H "Content-Type: application/json" \ -d '{"idea": "AI coding workflow assistant", "product_doc": "Need repo indexing and recommendation"}' + +# 导出 JSON 报告 +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/export?format=json" \ + -H "Content-Type: application/json" \ + -d '{"idea": "AI coding workflow assistant", "product_doc": "Need repo indexing and recommendation"}' + +# 批量评估多个想法 +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/batch" \ + -H "Content-Type: application/json" \ + -d '{"items":[{"idea":"Open-source API mocking tool","product_doc":"Need scenario replay"},{"idea":"PR review assistant for OSS maintainers","product_doc":"Need triage automation"}],"limit":6,"max_concurrency":2,"per_item_timeout_seconds":30}' + +# 导出批量评估 Markdown 报告 +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/batch/export?format=markdown" \ + -H "Content-Type: application/json" \ + -d '{"items":[{"idea":"Open-source API mocking tool","product_doc":"Need scenario replay"},{"idea":"PR review assistant for OSS maintainers","product_doc":"Need triage automation"}],"limit":6,"max_concurrency":2,"per_item_timeout_seconds":30}' + +# 导出批量评估 JSON 报告 +curl -X POST "http://127.0.0.1:8010/api/v1/idea/assess/batch/export?format=json" \ + -H "Content-Type: application/json" \ + -d '{"items":[{"idea":"Open-source API mocking tool","product_doc":"Need scenario replay"},{"idea":"PR review assistant for OSS maintainers","product_doc":"Need triage automation"}],"limit":6,"max_concurrency":2,"per_item_timeout_seconds":30}' ``` 响应包含: @@ -153,6 +246,10 @@ curl -X POST http://localhost:8000/api/v1/idea/assess \ - action_recommendation(build/fork/integrate) - decision_signals(判定依据) - similar_projects 与 evidence_snippets +- 导出接口支持 markdown/json 两种报告格式 +- 批量接口返回逐条结果和 verdict 统计 +- 批量接口支持 max_concurrency 与 per_item_timeout_seconds +- 批量导出接口支持 markdown/json 两种报告格式 ## 自托管 diff --git a/cli/__init__.py b/cli/__init__.py index b25b240..31a027c 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -2,9 +2,15 @@ from __future__ import annotations +import json import os +import signal +import subprocess import sys +import time from pathlib import Path +from urllib.error import URLError +from urllib.request import urlopen import typer from rich.console import Console @@ -22,16 +28,333 @@ help="🦉 Owlscope CLI — search, evaluate, and compare open-source projects and Agent Skills.", no_args_is_help=True, ) +ops_app = typer.Typer(help="Release and deployment operations (local-first, docker optional).") +app.add_typer(ops_app, name="ops") console = Console() +ROOT_DIR = Path(__file__).resolve().parent.parent +STATE_FILE = Path("/tmp/owlscope-cli-runtime.json") def _get_client() -> OwlscopeClient: return OwlscopeClient(OwlscopeConfig( - base_url=os.environ.get("OWLSCOPE_API_URL", "http://localhost:8000"), + base_url=os.environ.get("OWLSCOPE_API_URL", "http://127.0.0.1:8010"), api_key=os.environ.get("OWLSCOPE_API_KEY"), )) +def _run(cmd: list[str], env: dict[str, str] | None = None, cwd: Path | None = None) -> None: + subprocess.run(cmd, cwd=str(cwd or ROOT_DIR), env=env, check=True) + + +def _load_state() -> dict[str, object]: + if not STATE_FILE.exists(): + return {} + try: + return json.loads(STATE_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _save_state(state: dict[str, object]) -> None: + STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8") + + +def _clear_state() -> None: + try: + STATE_FILE.unlink() + except FileNotFoundError: + pass + + +def _is_pid_running(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _stop_pid(pid: int, name: str, timeout_s: float = 8.0) -> bool: + if not _is_pid_running(pid): + return False + + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return False + + deadline = time.time() + timeout_s + while time.time() < deadline: + if not _is_pid_running(pid): + return True + time.sleep(0.2) + + try: + os.kill(pid, signal.SIGKILL) + return True + except OSError: + console.print(f"[yellow]Unable to force-stop {name} process (pid={pid})[/yellow]") + return False + + +def _is_healthy(api_base: str) -> bool: + try: + with urlopen(f"{api_base}/health", timeout=2.0) as resp: + return resp.status == 200 + except (URLError, TimeoutError, ValueError): + return False + + +def _wait_health(api_base: str, timeout_s: int = 90) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if _is_healthy(api_base): + return True + time.sleep(1) + return False + + +def _is_url_ok(url: str) -> bool: + try: + with urlopen(url, timeout=2.0) as resp: + return 200 <= resp.status < 500 + except (URLError, TimeoutError, ValueError): + return False + + +def _wait_url(url: str, timeout_s: int = 120) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if _is_url_ok(url): + return True + time.sleep(1) + return False + + +def _deploy_docker(api_base: str, web_base: str, with_web: bool) -> None: + console.print("[bold cyan]Deploying with Docker (backup path)...[/bold cyan]") + _run([ + "docker", + "compose", + "-f", + "docker-compose.yml", + "-f", + "docker-compose.prod.yml", + "up", + "-d", + "--build", + ]) + env = os.environ.copy() + env["OWLSCOPE_API_BASE"] = api_base + _run(["bash", "scripts/post_deploy_check.sh"], env=env) + if with_web: + env["OWLSCOPE_WEB_BASE"] = web_base + _run(["bash", "scripts/check_web.sh"], env=env) + + +@ops_app.command("preflight") +def ops_preflight( + integration: bool = typer.Option(False, "--integration", help="Include integration compare test"), +) -> None: + """Run one-command release preflight checks.""" + env = os.environ.copy() + env["OWLSCOPE_RELEASE_CHECK_INTEGRATION"] = "1" if integration else "0" + _run(["bash", "scripts/release_check.sh"], env=env) + console.print("[green]Preflight checks passed.[/green]") + + +@ops_app.command("check") +def ops_check( + api_base: str = typer.Option("http://127.0.0.1:8010", help="API base URL"), + with_web: bool = typer.Option(False, "--with-web", help="Also validate web endpoint"), + web_base: str = typer.Option("http://127.0.0.1:3100", help="Web base URL"), +) -> None: + """Run post-deploy checks against a running API.""" + env = os.environ.copy() + env["OWLSCOPE_API_BASE"] = api_base + _run(["bash", "scripts/post_deploy_check.sh"], env=env) + if with_web: + env["OWLSCOPE_WEB_BASE"] = web_base + _run(["bash", "scripts/check_web.sh"], env=env) + console.print("[green]Post-deploy checks passed.[/green]") + + +@ops_app.command("deploy") +def ops_deploy( + mode: str = typer.Option("local", help="Deployment mode: local or docker"), + api_host: str = typer.Option("127.0.0.1", help="API host for local mode"), + api_port: int = typer.Option(8010, help="API port for local mode"), + with_web: bool = typer.Option(False, "--with-web", help="Also ensure web is started and validated"), + web_host: str = typer.Option("127.0.0.1", help="Web host for local mode"), + web_port: int = typer.Option(3100, help="Web port for local mode"), + web_mode: str = typer.Option("start", help="Web mode for local: start or dev"), + detached: bool = typer.Option(True, "--detached/--no-detached", help="Keep CLI-started processes running after checks"), + fallback_docker: bool = typer.Option(True, "--fallback-docker/--no-fallback-docker", help="Use docker deployment if local mode fails"), +) -> None: + """Deploy with local-first strategy; optionally fallback to docker.""" + selected_mode = mode.strip().lower() + api_base = f"http://{api_host}:{api_port}" + web_base = f"http://{web_host}:{web_port}" + selected_web_mode = web_mode.strip().lower() + + if selected_mode not in {"local", "docker"}: + console.print("[red]mode must be 'local' or 'docker'[/red]") + raise typer.Exit(1) + + if selected_web_mode not in {"start", "dev"}: + console.print("[red]web-mode must be 'start' or 'dev'[/red]") + raise typer.Exit(1) + + if selected_mode == "docker": + _deploy_docker(api_base, web_base, with_web) + console.print("[green]Docker deploy completed.[/green]") + return + + env = os.environ.copy() + started_state: dict[str, object] = {} + api_proc: subprocess.Popen[str] | None = None + web_proc: subprocess.Popen[str] | None = None + try: + _run([sys.executable, "scripts/validate_env.py", "--mode", "prod"], env=env) + + log_file = "/tmp/owlscope-cli-local-api.log" + web_log_file = "/tmp/owlscope-cli-local-web.log" + + if not _is_healthy(api_base): + console.print("[bold cyan]Starting local API (direct mode)...[/bold cyan]") + log_handle = open(log_file, "a", encoding="utf-8") + api_proc = subprocess.Popen( + [ + sys.executable, + "-m", + "uvicorn", + "src.api.main:app", + "--host", + api_host, + "--port", + str(api_port), + "--log-level", + "warning", + ], + cwd=str(ROOT_DIR), + stdout=log_handle, + stderr=subprocess.STDOUT, + text=True, + ) + + if not _wait_health(api_base): + raise RuntimeError("Local API did not become healthy in time") + + started_state["api"] = { + "pid": api_proc.pid, + "host": api_host, + "port": api_port, + "base": api_base, + "log": log_file, + } + + env["OWLSCOPE_API_BASE"] = api_base + _run(["bash", "scripts/post_deploy_check.sh"], env=env) + + if with_web: + if not _is_url_ok(web_base): + console.print("[bold cyan]Starting local Web...[/bold cyan]") + web_env = os.environ.copy() + web_env["OWLSCOPE_API_ORIGIN"] = api_base + web_log_handle = open(web_log_file, "a", encoding="utf-8") + if selected_web_mode == "start": + _run(["npm", "run", "build"], env=web_env, cwd=ROOT_DIR / "web") + web_proc = subprocess.Popen( + ["npx", "next", "start", "-H", web_host, "-p", str(web_port)], + cwd=str(ROOT_DIR / "web"), + env=web_env, + stdout=web_log_handle, + stderr=subprocess.STDOUT, + text=True, + ) + else: + web_proc = subprocess.Popen( + ["npx", "next", "dev", "-H", web_host, "-p", str(web_port)], + cwd=str(ROOT_DIR / "web"), + env=web_env, + stdout=web_log_handle, + stderr=subprocess.STDOUT, + text=True, + ) + + if not _wait_url(web_base): + raise RuntimeError("Local web did not become ready in time") + + started_state["web"] = { + "pid": web_proc.pid, + "host": web_host, + "port": web_port, + "base": web_base, + "mode": selected_web_mode, + "log": web_log_file, + } + + env["OWLSCOPE_WEB_BASE"] = web_base + _run(["bash", "scripts/check_web.sh"], env=env) + + console.print("[green]Local deployment checks passed.[/green]") + if started_state and detached: + _save_state(started_state) + console.print(f"[dim]Runtime state saved: {STATE_FILE}[/dim]") + if "api" in started_state: + console.print(f"[dim]Local API started by CLI, PID={started_state['api']['pid']}, logs={started_state['api']['log']}[/dim]") + if "web" in started_state: + console.print(f"[dim]Local Web started by CLI, PID={started_state['web']['pid']}, logs={started_state['web']['log']}[/dim]") + console.print("[dim]Use 'owlscope ops stop' to stop CLI-managed processes.[/dim]") + elif started_state and not detached: + if "web" in started_state: + _stop_pid(int(started_state["web"]["pid"]), "web") + if "api" in started_state: + _stop_pid(int(started_state["api"]["pid"]), "api") + _clear_state() + console.print("[dim]Stopped CLI-started processes because --no-detached was set.[/dim]") + except Exception as exc: + if started_state.get("web"): + _stop_pid(int(started_state["web"]["pid"]), "web") + if started_state.get("api"): + _stop_pid(int(started_state["api"]["pid"]), "api") + _clear_state() + console.print(f"[yellow]Local mode failed: {exc}[/yellow]") + if not fallback_docker: + raise typer.Exit(1) + _deploy_docker(api_base, web_base, with_web) + console.print("[green]Fallback docker deploy completed.[/green]") + + +@ops_app.command("stop") +def ops_stop() -> None: + """Stop API/Web processes that were started by `owlscope ops deploy`.""" + state = _load_state() + if not state: + console.print("[yellow]No CLI-managed runtime state found.[/yellow]") + return + + stopped_any = False + + web = state.get("web") + if isinstance(web, dict) and "pid" in web: + pid = int(web["pid"]) + if _stop_pid(pid, "web"): + console.print(f"[green]Stopped web process (pid={pid}).[/green]") + stopped_any = True + + api = state.get("api") + if isinstance(api, dict) and "pid" in api: + pid = int(api["pid"]) + if _stop_pid(pid, "api"): + console.print(f"[green]Stopped api process (pid={pid}).[/green]") + stopped_any = True + + _clear_state() + if not stopped_any: + console.print("[yellow]State found, but managed processes were already stopped.[/yellow]") + + @app.command() def search( query: str = typer.Argument(..., help="Natural language search query"), diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..0e104e6 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,24 @@ +services: + api: + restart: unless-stopped + command: uvicorn src.api.main:app --host 0.0.0.0 --port 8000 + volumes: [] + environment: + - DEBUG=false + + web: + restart: unless-stopped + volumes: [] + environment: + - NODE_ENV=production + - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://127.0.0.1:8010} + - OWLSCOPE_API_ORIGIN=${OWLSCOPE_API_ORIGIN:-http://api:8000} + + postgres: + restart: unless-stopped + + redis: + restart: unless-stopped + + qdrant: + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 5566116..bdc35fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: context: . dockerfile: docker/Dockerfile.api ports: - - "8000:8000" + - "8010:8000" env_file: - .env depends_on: @@ -26,11 +26,12 @@ services: context: ./web dockerfile: ../docker/Dockerfile.web ports: - - "3000:3000" + - "3100:3000" depends_on: - api environment: - - NEXT_PUBLIC_API_URL=http://api:8000 + - NEXT_PUBLIC_API_URL=http://127.0.0.1:8010 + - OWLSCOPE_API_ORIGIN=http://api:8000 volumes: - ./web/src:/app/src diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..b88061d --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,56 @@ +# Deployment Runbook + +This runbook covers a safe baseline deployment for Owlscope. + +## 1. Preflight + +- Validate env vars: + - `python scripts/validate_env.py --mode prod` +- Run release checks: + - `bash scripts/release_check.sh` +- Run integration compare test: + - `bash scripts/run_integration_compare.sh` + +## 2. Build and Launch + +- Build web: + - `cd web && npm ci && npm run build` +- CLI-first launch (local direct mode): + - `owlscope ops deploy --mode local` +- CLI launch with frontend validation: + - `owlscope ops deploy --mode local --with-web` +- Ephemeral run (auto-stop after checks): + - `owlscope ops deploy --mode local --with-web --no-detached` +- Start services (production profile with override): + - `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` +- CLI docker backup path: + - `owlscope ops deploy --mode docker` + - `owlscope ops deploy --mode docker --with-web` + +## 3. Post-Deploy Verification + +- Health: + - `curl http://127.0.0.1:8010/health` +- Admin monitor: + - `curl http://127.0.0.1:8010/api/v1/admin/monitor` +- One-command post-deploy checks: + - `bash scripts/post_deploy_check.sh` + - `owlscope ops check` + - `owlscope ops check --with-web` +- Stop CLI-managed local processes: + - `owlscope ops stop` +- Rate-limit headers: + - `bash scripts/check_rate_limit.sh` +- Core smoke APIs: + - `/api/v1/search` + - `/api/v1/evaluate/{id}` + - `/api/v1/compare` + - `/api/v1/idea/assess` + +## 4. Rollback + +- Roll app image/version back to the previous known-good release. +- Run DB rollback only when schema migration requires it. +- Re-run smoke checks after rollback. + +See also: `docs/migrations.md` and `docs/smoke-test.md`. diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..06a27b6 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,34 @@ +# Database Migration and Rollback Guide + +Use Alembic for schema changes in production. + +## Forward Migration + +```bash +alembic upgrade head +``` + +## Rollback One Version + +```bash +alembic downgrade -1 +``` + +## Rollback to Specific Revision + +```bash +alembic downgrade +``` + +## Safe Procedure + +1. Backup database before migration. +2. Run migration in staging first. +3. Deploy app with matching code and schema. +4. If rollback is needed, roll app first when compatible, then rollback DB. +5. Re-run smoke tests after rollback. + +## Verification + +- `alembic current` +- `alembic history --verbose` diff --git a/docs/release-checklist.md b/docs/release-checklist.md index ab65cbc..9793669 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -5,9 +5,13 @@ Use this checklist before publishing a new release. ## Code and Tests - Run backend tests: `pytest -q` +- Run integration compare test: `bash scripts/run_integration_compare.sh` - Build frontend: `cd web && npm run build` - Run one-command release validation: `bash scripts/release_check.sh` +- Run full preflight in CI: GitHub Actions `Release Preflight` - Verify API health locally: `/health` +- Verify monitor endpoint: `/api/v1/admin/monitor` +- Verify rate-limit headers: `bash scripts/check_rate_limit.sh` - Smoke test critical endpoints: - `/api/v1/search` - `/api/v1/evaluate/{id}` @@ -20,11 +24,15 @@ Use this checklist before publishing a new release. - Update `README.zh-CN.md` for major user-facing changes - Update `docs/ROADMAP.md` if priorities changed - Confirm `docs/self-hosting.md` still matches actual setup +- Confirm deployment runbook: `docs/deployment.md` +- Confirm smoke checklist: `docs/smoke-test.md` +- Confirm migration/rollback guide: `docs/migrations.md` ## Versioning - Bump version in `pyproject.toml` - Prepare release notes (summary, changes, risks) +- Include rollback notes and verification evidence - Tag release: `vX.Y.Z` ## GitHub Release diff --git a/docs/releases/TEMPLATE.md b/docs/releases/TEMPLATE.md new file mode 100644 index 0000000..189beb8 --- /dev/null +++ b/docs/releases/TEMPLATE.md @@ -0,0 +1,38 @@ +# Release Notes Template + +## Summary + +- What changed in this release. +- Why this release matters. + +## Scope + +- Backend: +- Web: +- Data / Migrations: +- Docs: + +## Verification Evidence + +- Backend tests: +- Integration compare test: +- Web build: +- Release preflight: +- Smoke test: + +## Risks + +- Known risks: +- Mitigations: + +## Rollback Plan + +1. Roll application to previous release artifact. +2. If schema rollback is required, run Alembic downgrade. +3. Re-run smoke tests and monitor endpoint checks. + +## Operator Notes + +- Required environment updates: +- Config flags changed: +- Post-release watch items: diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 4530536..2693cbd 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -19,9 +19,9 @@ docker compose up -d ``` Services: -- Web: http://localhost:3000 -- API: http://localhost:8000 -- Swagger: http://localhost:8000/docs +- Web: http://127.0.0.1:3100 +- API: http://127.0.0.1:8010 +- Swagger: http://127.0.0.1:8010/docs ## Environment Variables @@ -34,6 +34,12 @@ For LLM-backed features, set at least one provider key, for example: Owlscope has deterministic fallbacks for some retrieval flows when model services are unavailable. +Validate environment before release/deploy: + +```bash +python scripts/validate_env.py --mode prod +``` + ## Local Dev Without Docker ### Backend @@ -42,7 +48,7 @@ Owlscope has deterministic fallbacks for some retrieval flows when model service python -m venv .venv source .venv/bin/activate pip install -e ".[dev]" -uvicorn src.api.main:app --reload --port 8000 +uvicorn src.api.main:app --reload --host 127.0.0.1 --port 8010 ``` ### Frontend @@ -56,8 +62,42 @@ npm run dev ## Health Checks ```bash -curl http://localhost:8000/health -curl http://localhost:8000/api/v1/admin/monitor +curl http://127.0.0.1:8010/health +curl http://127.0.0.1:8010/api/v1/admin/monitor +``` + +## Release Preflight + +```bash +# Includes tests, optional integration, web build, and smoke checks. +bash scripts/release_check.sh + +# Enable integration compare in the same run +OWLSCOPE_RELEASE_CHECK_INTEGRATION=1 bash scripts/release_check.sh +``` + +## Production Compose Launch + +```bash +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +``` + +Then run post-deploy checks: + +```bash +bash scripts/post_deploy_check.sh +``` + +CLI-first alternative (direct mode first, docker backup optional): + +```bash +owlscope ops preflight +owlscope ops deploy --mode local +owlscope ops deploy --mode local --with-web +owlscope ops deploy --mode local --with-web --no-detached +# backup path: owlscope ops deploy --mode docker +owlscope ops check --with-web +owlscope ops stop ``` ## Data Notes @@ -71,3 +111,6 @@ curl http://localhost:8000/api/v1/admin/monitor - Run a persistent Qdrant instance (non-memory). - Put API behind a reverse proxy with TLS. - Configure log shipping and monitor /api/v1/admin/monitor. +- Follow `docs/deployment.md` for release sequence. +- Follow `docs/migrations.md` for migration and rollback. +- Follow `docs/smoke-test.md` before and after deployment. diff --git a/docs/smoke-test.md b/docs/smoke-test.md new file mode 100644 index 0000000..e548d9a --- /dev/null +++ b/docs/smoke-test.md @@ -0,0 +1,27 @@ +# Smoke Test Checklist + +Run these checks before production release and immediately after deployment. + +## API + +- `GET /health` returns 200. +- `GET /api/v1/admin/monitor` returns status and telemetry. +- `POST /api/v1/search` returns recommendations. +- `GET /api/v1/evaluate/{resource_id}` returns 200 or expected 404. +- `POST /api/v1/compare` returns 200 or expected 404 when data not seeded. +- `POST /api/v1/idea/assess` returns verdict and recommendation. +- `POST /api/v1/idea/assess/export?format=markdown` returns markdown. +- `POST /api/v1/idea/assess/batch/export?format=json` returns JSON envelope. + +## Web + +- Home page loads. +- Idea page can run single assessment. +- Batch export works for markdown and JSON. +- Export history appears and re-export works. + +## Platform + +- Rate-limit headers present on API responses. +- Logs are collected and visible. +- DB and Redis connectivity verified. diff --git a/scripts/check_rate_limit.sh b/scripts/check_rate_limit.sh new file mode 100755 index 0000000..c3a57a8 --- /dev/null +++ b/scripts/check_rate_limit.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_BASE="${OWLSCOPE_API_BASE:-http://127.0.0.1:8010}" + +echo "[rate-limit] Checking rate-limit headers on /api/v1/search ..." +headers=$(mktemp) +code=$(curl -s -o /tmp/owlscope-rate-limit.out -D "$headers" -w "%{http_code}" \ + -X POST "$API_BASE/api/v1/search" \ + -H "Content-Type: application/json" \ + -d '{"query":"python web framework"}') + +if [[ "$code" != "200" ]]; then + echo "Unexpected response code: $code" + cat /tmp/owlscope-rate-limit.out + exit 1 +fi + +if ! grep -qi "^X-RateLimit-Limit:" "$headers"; then + echo "Missing X-RateLimit-Limit header" + exit 1 +fi + +if ! grep -qi "^X-RateLimit-Remaining:" "$headers"; then + echo "Missing X-RateLimit-Remaining header" + exit 1 +fi + +echo "[rate-limit] Headers present and endpoint reachable." diff --git a/scripts/check_web.sh b/scripts/check_web.sh new file mode 100755 index 0000000..eac287e --- /dev/null +++ b/scripts/check_web.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +WEB_BASE="${OWLSCOPE_WEB_BASE:-http://127.0.0.1:3100}" + +echo "[web-check] Waiting for web readiness ..." +for _ in {1..120}; do + if curl -fsS "$WEB_BASE" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! curl -fsS "$WEB_BASE" >/dev/null 2>&1; then + echo "[web-check] Web did not become ready in time: $WEB_BASE" + exit 1 +fi + +echo "[web-check] Checking /idea page ..." +code=$(curl -s -o /tmp/owlscope-web-idea.out -w "%{http_code}" "$WEB_BASE/idea") +if [[ "$code" != "200" ]]; then + echo "[web-check] Unexpected /idea status: $code" + cat /tmp/owlscope-web-idea.out + exit 1 +fi + +echo "[web-check] Web checks passed." diff --git a/scripts/post_deploy_check.sh b/scripts/post_deploy_check.sh new file mode 100755 index 0000000..9dcf2ec --- /dev/null +++ b/scripts/post_deploy_check.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +API_BASE="${OWLSCOPE_API_BASE:-http://127.0.0.1:8010}" + +echo "[post-deploy] Waiting for API readiness ..." +for _ in {1..90}; do + if curl -fsS "$API_BASE/health" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! curl -fsS "$API_BASE/health" >/dev/null 2>&1; then + echo "[post-deploy] API did not become ready in time" + exit 1 +fi + +echo "[post-deploy] Checking /health ..." +curl -fsS "$API_BASE/health" >/dev/null + +echo "[post-deploy] Checking /api/v1/admin/monitor ..." +curl -fsS "$API_BASE/api/v1/admin/monitor" >/dev/null + +echo "[post-deploy] Checking search endpoint ..." +curl -fsS -X POST "$API_BASE/api/v1/search" \ + -H "Content-Type: application/json" \ + -d '{"query":"python web framework"}' >/dev/null + +echo "[post-deploy] Checking idea assess endpoint ..." +curl -fsS -X POST "$API_BASE/api/v1/idea/assess" \ + -H "Content-Type: application/json" \ + -d '{"idea":"Open-source assistant for startup idea validation"}' >/dev/null + +echo "[post-deploy] Checking batch export markdown endpoint ..." +code=$(curl -s -o /tmp/owlscope-post-deploy-batch.out -w "%{http_code}" \ + -X POST "$API_BASE/api/v1/idea/assess/batch/export?format=markdown" \ + -H "Content-Type: application/json" \ + -d '{"items":[{"idea":"Open-source API mocking tool"},{"idea":"PR review assistant for OSS maintainers"}],"limit":6,"max_concurrency":2,"per_item_timeout_seconds":30}') +if [[ "$code" != "200" ]]; then + echo "Unexpected batch export status: $code" + cat /tmp/owlscope-post-deploy-batch.out + exit 1 +fi + +echo "[post-deploy] Checking rate-limit headers ..." +bash scripts/check_rate_limit.sh + +echo "[post-deploy] All checks passed." diff --git a/scripts/release_check.sh b/scripts/release_check.sh index 9310179..a841a4d 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -4,52 +4,76 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" -echo "[1/4] Running backend tests..." -source .venv/bin/activate +API_HOST="${OWLSCOPE_API_HOST:-127.0.0.1}" +API_PORT="${OWLSCOPE_API_PORT:-8010}" +API_BASE="http://${API_HOST}:${API_PORT}" +RUN_INTEGRATION="${OWLSCOPE_RELEASE_CHECK_INTEGRATION:-0}" + +echo "[1/6] Validating deployment environment..." +python scripts/validate_env.py --mode dev + +echo "[2/6] Running backend tests..." +if [[ -f ".venv/bin/activate" ]]; then + # Local development path + source .venv/bin/activate +else + # CI path typically installs deps into the runner Python environment + echo "[release-check] .venv not found, using system Python environment" +fi pytest -q -m "not integration" -echo "[2/4] Building web app..." +if [[ "$RUN_INTEGRATION" == "1" ]]; then + echo "[3/6] Running integration compare test..." + export OWLSCOPE_RUN_INTEGRATION=1 + pytest -q -m integration tests/integration/test_compare_blackbox.py +else + echo "[3/6] Skipping integration compare test (set OWLSCOPE_RELEASE_CHECK_INTEGRATION=1 to enable)" +fi + +echo "[4/6] Building web app..." pushd web >/dev/null npm run build popd >/dev/null -echo "[3/4] Starting API for smoke checks..." -lsof -ti:8000 | xargs kill -9 2>/dev/null || true -python -m uvicorn src.api.main:app --host 0.0.0.0 --port 8000 --log-level warning >/tmp/owlscope-release-check.log 2>&1 & +echo "[5/6] Starting API for smoke checks..." +lsof -ti:"$API_PORT" | xargs kill -9 2>/dev/null || true +python -m uvicorn src.api.main:app --host "$API_HOST" --port "$API_PORT" --log-level warning >/tmp/owlscope-release-check.log 2>&1 & API_PID=$! trap 'kill "$API_PID" 2>/dev/null || true' EXIT for _ in {1..90}; do - if curl -fsS "http://localhost:8000/health" >/dev/null 2>&1; then + if curl -fsS "$API_BASE/health" >/dev/null 2>&1; then break fi sleep 1 done -if ! curl -fsS "http://localhost:8000/health" >/dev/null 2>&1; then +if ! curl -fsS "$API_BASE/health" >/dev/null 2>&1; then echo "API health check failed" echo "--- recent API logs ---" tail -n 80 /tmp/owlscope-release-check.log || true exit 1 fi -echo "[4/4] Smoke testing key endpoints..." -curl -fsS -X POST "http://localhost:8000/api/v1/search" \ +echo "[6/6] Smoke testing key endpoints..." +curl -fsS -X POST "$API_BASE/api/v1/search" \ -H "Content-Type: application/json" \ -d '{"query":"python web framework"}' >/dev/null -curl -fsS -X POST "http://localhost:8000/api/v1/idea/assess" \ +curl -fsS "$API_BASE/api/v1/admin/monitor" >/dev/null + +curl -fsS -X POST "$API_BASE/api/v1/idea/assess" \ -H "Content-Type: application/json" \ -d '{"idea":"Open-source assistant for startup idea validation"}' >/dev/null # Some environments may not have seeded comparison resources yet. -EVALUATE_CODE=$(curl -s -o /tmp/owlscope-evaluate.out -w "%{http_code}" "http://localhost:8000/api/v1/evaluate/github:library:encode/httpx") +EVALUATE_CODE=$(curl -s -o /tmp/owlscope-evaluate.out -w "%{http_code}" "$API_BASE/api/v1/evaluate/github:library:encode/httpx") if [[ "$EVALUATE_CODE" != "200" && "$EVALUATE_CODE" != "404" ]]; then echo "Unexpected /evaluate status: $EVALUATE_CODE" exit 1 fi -COMPARE_CODE=$(curl -s -o /tmp/owlscope-compare.out -w "%{http_code}" -X POST "http://localhost:8000/api/v1/compare" \ +COMPARE_CODE=$(curl -s -o /tmp/owlscope-compare.out -w "%{http_code}" -X POST "$API_BASE/api/v1/compare" \ -H "Content-Type: application/json" \ -d '{"projects":["github:library:fastapi/fastapi","github:library:pallets/flask"]}') if [[ "$COMPARE_CODE" != "200" && "$COMPARE_CODE" != "404" ]]; then @@ -57,4 +81,6 @@ if [[ "$COMPARE_CODE" != "200" && "$COMPARE_CODE" != "404" ]]; then exit 1 fi +bash scripts/check_rate_limit.sh + echo "Release check passed." diff --git a/scripts/run_integration_compare.sh b/scripts/run_integration_compare.sh new file mode 100755 index 0000000..a43f84b --- /dev/null +++ b/scripts/run_integration_compare.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +echo "[integration] Ensuring local infra is up (postgres, redis)..." +if ! docker compose up -d postgres redis; then + echo "[integration] docker compose failed (often due port conflicts)." + echo "[integration] Continuing with currently running local postgres/redis if reachable." +fi + +echo "[integration] Running black-box compare integration test..." +if [[ -f ".venv/bin/activate" ]]; then + source .venv/bin/activate +else + echo "[integration] .venv not found, using system Python environment" +fi +export OWLSCOPE_RUN_INTEGRATION=1 +pytest -q -m integration tests/integration/test_compare_blackbox.py + +echo "[integration] Passed." diff --git a/scripts/validate_env.py b/scripts/validate_env.py new file mode 100644 index 0000000..5a6f542 --- /dev/null +++ b/scripts/validate_env.py @@ -0,0 +1,44 @@ +"""Validate required runtime environment variables for deployment stages.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from src.config import Settings + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate Owlscope deployment environment") + parser.add_argument("--mode", choices=["dev", "prod"], default="prod") + args = parser.parse_args() + + resolved = Settings() + missing: list[str] = [] + + if not resolved.database_url: + missing.append("DATABASE_URL") + if not resolved.redis_url: + missing.append("REDIS_URL") + + if args.mode == "prod": + if resolved.secret_key == "change-me-to-a-random-secret-key": + missing.append("SECRET_KEY(non-default)") + + if missing: + print("[env-validate] Missing required settings:") + for item in sorted(set(missing)): + print(f"- {item}") + return 1 + + print(f"[env-validate] OK ({args.mode})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/api/main.py b/src/api/main.py index ae95c75..028eceb 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -15,7 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware from redis.asyncio import Redis -from src.api.routes import search, evaluate, recommend, resources, sessions, auth, idea +from src.api.routes import search, evaluate, recommend, resources, sessions, auth, idea, admin from src.api.middleware.rate_limit import RateLimitMiddleware from src.api.middleware.access_log import AccessLogMiddleware from src.config import settings @@ -111,6 +111,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.include_router(resources.router, prefix="/api/v1", tags=["Resources"]) app.include_router(sessions.router, prefix="/api/v1", tags=["Sessions"]) app.include_router(auth.router, prefix="/api/v1", tags=["Auth"]) +app.include_router(admin.router, prefix="/api/v1", tags=["Admin"]) @app.get("/health") diff --git a/src/api/middleware/access_log.py b/src/api/middleware/access_log.py index 13d090e..c200d00 100644 --- a/src/api/middleware/access_log.py +++ b/src/api/middleware/access_log.py @@ -10,6 +10,8 @@ from starlette.requests import Request from starlette.responses import Response +from src.api.telemetry import record_request + logger = logging.getLogger("owlscope.access") # Paths to skip (noisy health checks, static assets) @@ -42,3 +44,4 @@ async def dispatch(self, request: Request, call_next) -> Response: status, elapsed_ms, ) + record_request(status_code=status, latency_ms=elapsed_ms) diff --git a/src/api/routes/admin.py b/src/api/routes/admin.py new file mode 100644 index 0000000..82611cb --- /dev/null +++ b/src/api/routes/admin.py @@ -0,0 +1,18 @@ +"""Operational/admin endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from src.api.telemetry import snapshot + +router = APIRouter() + + +@router.get("/admin/monitor") +async def monitor() -> dict[str, object]: + """Return lightweight runtime monitoring counters.""" + return { + "status": "ok", + "telemetry": snapshot(), + } diff --git a/src/api/routes/idea.py b/src/api/routes/idea.py index e98a333..faa9423 100644 --- a/src/api/routes/idea.py +++ b/src/api/routes/idea.py @@ -6,17 +6,24 @@ from __future__ import annotations +import asyncio import re +from contextlib import suppress -from fastapi import APIRouter +from fastapi import APIRouter, Query +from fastapi.responses import PlainTextResponse from src.core.llm.adapter import llm from src.core.llm.router import CallLevel from src.core.query_engine.engine import QueryEngine from src.models.schemas import ( DecisionSignals, + IdeaAssessBatchExportResponse, DifferentiationOpportunity, + IdeaAssessBatchRequest, + IdeaAssessBatchResponse, IdeaAssessCandidate, + IdeaAssessExportResponse, IdeaAssessRequest, IdeaAssessResponse, ) @@ -175,9 +182,89 @@ def _action_recommendation(verdict: str, probability: float) -> tuple[str, str]: ) -@router.post("/idea/assess", response_model=IdeaAssessResponse) -async def assess_idea(request: IdeaAssessRequest) -> IdeaAssessResponse: - """Assess if an idea is already implemented and suggest similar projects.""" +def _render_markdown_report(assessment: IdeaAssessResponse) -> str: + lines: list[str] = [ + "# Idea Assessment Report", + "", + "## Input", + f"- Idea: {assessment.idea}", + "", + "## Verdict", + f"- Verdict: {assessment.verdict}", + f"- Confidence: {assessment.confidence}", + f"- Existing project probability: {assessment.existing_project_probability}", + f"- Recommended action: {assessment.action_recommendation}", + f"- Action rationale: {assessment.action_rationale}", + "", + "## Summary", + assessment.summary, + "", + "## Decision Signals", + f"- Candidate count: {assessment.decision_signals.candidate_count}", + f"- Top score: {assessment.decision_signals.top_score}", + f"- Base probability: {assessment.decision_signals.base_probability}", + f"- Calibrated probability: {assessment.decision_signals.calibrated_probability}", + f"- Avg keyword hits: {assessment.decision_signals.avg_keyword_hits}", + f"- Non-empty reason ratio: {assessment.decision_signals.nonempty_reason_ratio}", + "", + "## Similar Projects", + ] + + if assessment.similar_projects: + for item in assessment.similar_projects: + lines.append( + f"- {item.rank}. {item.project} ({item.source or 'unknown'}, score={item.score})" + ) + lines.append(f" - Match reason: {item.match_reason}") + if item.evidence_snippets: + lines.append(f" - Evidence: {' | '.join(item.evidence_snippets)}") + else: + lines.append("- No close matches found") + + lines.extend(["", "## Differentiation Opportunities"]) + for opp in assessment.differentiation_opportunities: + lines.append(f"- [{opp.category}] {opp.opportunity}") + lines.append(f" - Rationale: {opp.rationale}") + lines.append(f" - Effort: {opp.implementation_effort}") + + lines.extend(["", "## Next Actions"]) + for action in assessment.next_actions: + lines.append(f"- {action}") + + return "\n".join(lines) + + +def _render_batch_markdown_report(batch: IdeaAssessBatchResponse) -> str: + lines: list[str] = [ + "# Batch Idea Assessment Report", + "", + "## Summary", + f"- Total: {batch.total}", + f"- already_exists: {batch.already_exists}", + f"- similar_projects_exist: {batch.similar_projects_exist}", + f"- likely_novel: {batch.likely_novel}", + "", + "## Results", + ] + + for idx, item in enumerate(batch.results, start=1): + lines.extend( + [ + f"### {idx}. {item.idea}", + f"- Verdict: {item.verdict}", + f"- Confidence: {item.confidence}", + f"- Existing project probability: {item.existing_project_probability}", + f"- Recommended action: {item.action_recommendation}", + f"- Summary: {item.summary}", + "", + ] + ) + + return "\n".join(lines) + + +async def _assess_core(request: IdeaAssessRequest) -> IdeaAssessResponse: + """Core idea assessment logic shared by standard and export endpoints.""" combined_query = request.idea.strip() if request.product_doc: combined_query += "\n\n" + request.product_doc.strip() @@ -309,3 +396,134 @@ async def assess_idea(request: IdeaAssessRequest) -> IdeaAssessResponse: differentiation_opportunities=opportunities, next_actions=next_actions, ) + + +@router.post("/idea/assess", response_model=IdeaAssessResponse) +async def assess_idea(request: IdeaAssessRequest) -> IdeaAssessResponse: + """Assess if an idea is already implemented and suggest similar projects.""" + return await _assess_core(request) + + +@router.post("/idea/assess/export", response_model=IdeaAssessExportResponse) +async def export_assessment_report( + request: IdeaAssessRequest, + format: str = Query("markdown", pattern="^(markdown|json)$"), +) -> IdeaAssessExportResponse | PlainTextResponse: + """Export idea assessment as Markdown or JSON report.""" + assessment = await _assess_core(request) + + if format == "markdown": + markdown = _render_markdown_report(assessment) + return PlainTextResponse(content=markdown, media_type="text/markdown") + + return IdeaAssessExportResponse(report_type="json", assessment=assessment) + + +@router.post("/idea/assess/batch", response_model=IdeaAssessBatchResponse) +async def assess_idea_batch(request: IdeaAssessBatchRequest) -> IdeaAssessBatchResponse: + """Assess multiple ideas/PRDs in one request.""" + semaphore = asyncio.Semaphore(request.max_concurrency) + + async def _assess_one(item) -> IdeaAssessResponse: + single_request = IdeaAssessRequest( + idea=item.idea, + product_doc=item.product_doc, + limit=request.limit, + ) + + task: asyncio.Task[IdeaAssessResponse] | None = None + try: + async with semaphore: + task = asyncio.create_task(_assess_core(single_request)) + return await asyncio.wait_for( + task, + timeout=request.per_item_timeout_seconds, + ) + except asyncio.TimeoutError: + if task is not None: + task.cancel() + with suppress(asyncio.CancelledError, Exception): + await task + return IdeaAssessResponse( + idea=item.idea, + verdict="likely_novel", + confidence="low", + existing_project_probability=0.0, + action_recommendation="build", + action_rationale="Assessment timed out; retry with fewer items or higher timeout.", + decision_signals=DecisionSignals( + candidate_count=0, + top_score=0.0, + base_probability=0.0, + calibrated_probability=0.0, + avg_keyword_hits=0.0, + nonempty_reason_ratio=0.0, + ), + summary="Assessment timed out before retrieval and scoring completed.", + similar_projects=[], + differentiation_opportunities=_build_opportunities("likely_novel"), + next_actions=[ + "Retry batch assessment with a larger per_item_timeout_seconds.", + "Reduce batch size to improve per-item completion speed.", + ], + ) + except Exception: + return IdeaAssessResponse( + idea=item.idea, + verdict="likely_novel", + confidence="low", + existing_project_probability=0.0, + action_recommendation="build", + action_rationale="Assessment encountered an internal error; please retry.", + decision_signals=DecisionSignals( + candidate_count=0, + top_score=0.0, + base_probability=0.0, + calibrated_probability=0.0, + avg_keyword_hits=0.0, + nonempty_reason_ratio=0.0, + ), + summary="Assessment failed due to an internal processing error.", + similar_projects=[], + differentiation_opportunities=_build_opportunities("likely_novel"), + next_actions=[ + "Retry the request.", + "If failures persist, inspect service logs and provider connectivity.", + ], + ) + + tasks = [_assess_one(item) for item in request.items] + results = await asyncio.gather(*tasks) + + counters = { + "already_exists": 0, + "similar_projects_exist": 0, + "likely_novel": 0, + } + + for assessment in results: + if assessment.verdict in counters: + counters[assessment.verdict] += 1 + + return IdeaAssessBatchResponse( + total=len(results), + already_exists=counters["already_exists"], + similar_projects_exist=counters["similar_projects_exist"], + likely_novel=counters["likely_novel"], + results=results, + ) + + +@router.post("/idea/assess/batch/export", response_model=IdeaAssessBatchExportResponse) +async def export_batch_assessment_report( + request: IdeaAssessBatchRequest, + format: str = Query("markdown", pattern="^(markdown|json)$"), +) -> IdeaAssessBatchExportResponse | PlainTextResponse: + """Export batch idea assessments as Markdown or JSON report.""" + batch = await assess_idea_batch(request) + + if format == "markdown": + markdown = _render_batch_markdown_report(batch) + return PlainTextResponse(content=markdown, media_type="text/markdown") + + return IdeaAssessBatchExportResponse(report_type="json", assessment_batch=batch) diff --git a/src/api/telemetry.py b/src/api/telemetry.py new file mode 100644 index 0000000..507617f --- /dev/null +++ b/src/api/telemetry.py @@ -0,0 +1,44 @@ +"""In-process telemetry helpers for lightweight operational monitoring.""" + +from __future__ import annotations + +from collections import deque +from datetime import datetime, timezone +from threading import Lock + +_lock = Lock() +_started_at = datetime.now(timezone.utc) +_total_requests = 0 +_total_errors = 0 +_total_latency_ms = 0.0 +_recent_statuses: deque[int] = deque(maxlen=200) + + +def record_request(status_code: int, latency_ms: float) -> None: + """Record a request outcome for lightweight runtime monitoring.""" + global _total_requests, _total_errors, _total_latency_ms + with _lock: + _total_requests += 1 + _total_latency_ms += float(latency_ms) + _recent_statuses.append(int(status_code)) + if status_code >= 500: + _total_errors += 1 + + +def snapshot() -> dict[str, object]: + """Return an atomic snapshot of telemetry counters.""" + with _lock: + total_requests = _total_requests + total_errors = _total_errors + avg_latency_ms = (_total_latency_ms / total_requests) if total_requests else 0.0 + recent = list(_recent_statuses) + + return { + "started_at": _started_at.isoformat(), + "total_requests": total_requests, + "total_errors": total_errors, + "error_rate": round((total_errors / total_requests), 4) if total_requests else 0.0, + "avg_latency_ms": round(avg_latency_ms, 2), + "recent_requests": len(recent), + "recent_5xx": sum(1 for code in recent if code >= 500), + } diff --git a/src/models/schemas.py b/src/models/schemas.py index b149cc4..3dc43bf 100644 --- a/src/models/schemas.py +++ b/src/models/schemas.py @@ -123,6 +123,47 @@ class IdeaAssessResponse(BaseModel): generated_at: datetime = Field(default_factory=datetime.utcnow) +class IdeaAssessExportResponse(BaseModel): + report_type: str # markdown|json + assessment: IdeaAssessResponse + generated_at: datetime = Field(default_factory=datetime.utcnow) + + +class IdeaAssessBatchItem(BaseModel): + idea: str = Field(..., description="Core product idea or one-line concept") + product_doc: str | None = Field( + None, + description="Optional detailed PRD/brief for deeper similarity analysis", + ) + + +class IdeaAssessBatchRequest(BaseModel): + items: list[IdeaAssessBatchItem] = Field(..., min_length=1, max_length=20) + limit: int = Field(8, ge=3, le=20, description="How many similar projects to inspect for each idea") + max_concurrency: int = Field(4, ge=1, le=10, description="Max concurrent assessments in a batch") + per_item_timeout_seconds: float = Field( + 25.0, + ge=5.0, + le=120.0, + description="Timeout for each single idea assessment", + ) + + +class IdeaAssessBatchResponse(BaseModel): + total: int + already_exists: int + similar_projects_exist: int + likely_novel: int + results: list[IdeaAssessResponse] = Field(default_factory=list) + generated_at: datetime = Field(default_factory=datetime.utcnow) + + +class IdeaAssessBatchExportResponse(BaseModel): + report_type: str # markdown|json + assessment_batch: IdeaAssessBatchResponse + generated_at: datetime = Field(default_factory=datetime.utcnow) + + # ── Evaluation ─────────────────────────────────────────────────────────────── diff --git a/tests/integration/test_compare_blackbox.py b/tests/integration/test_compare_blackbox.py index 846ba38..aa6b45f 100644 --- a/tests/integration/test_compare_blackbox.py +++ b/tests/integration/test_compare_blackbox.py @@ -7,6 +7,7 @@ import socket import subprocess import sys +import tempfile import time from urllib.parse import urlparse from pathlib import Path @@ -151,6 +152,8 @@ def test_compare_endpoint_blackbox() -> None: _require_local_infra_or_skip(env["DATABASE_URL"], env["REDIS_URL"]) + log_file = tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=False) + proc = subprocess.Popen( [ sys.executable, @@ -166,7 +169,7 @@ def test_compare_endpoint_blackbox() -> None: ], cwd=str(root), env=env, - stdout=subprocess.PIPE, + stdout=log_file, stderr=subprocess.STDOUT, text=True, ) @@ -197,10 +200,21 @@ def test_compare_endpoint_blackbox() -> None: finally: proc.terminate() try: - _, logs = proc.communicate(timeout=10) + proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() - _, logs = proc.communicate(timeout=5) + proc.wait(timeout=5) + + try: + with open(log_file.name, encoding="utf-8") as fh: + logs = fh.read() + except Exception: + logs = "" + finally: + try: + os.unlink(log_file.name) + except Exception: + pass if logs: # Keep output concise while still preserving the error context. diff --git a/tests/test_api.py b/tests/test_api.py index 916b983..9261b20 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -76,6 +76,16 @@ async def test_discover_skills_endpoint(client: AsyncClient): assert resp.status_code == 200 +@pytest.mark.asyncio +async def test_admin_monitor_endpoint(client: AsyncClient): + resp = await client.get("/api/v1/admin/monitor") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert "telemetry" in data + assert "total_requests" in data["telemetry"] + + @pytest.mark.asyncio async def test_assess_idea_endpoint(client: AsyncClient): resp = await client.post( @@ -103,3 +113,113 @@ async def test_assess_idea_endpoint(client: AsyncClient): assert "calibrated_probability" in data["decision_signals"] if data["similar_projects"]: assert "evidence_snippets" in data["similar_projects"][0] + + +@pytest.mark.asyncio +async def test_assess_idea_export_markdown(client: AsyncClient): + resp = await client.post( + "/api/v1/idea/assess/export?format=markdown", + json={ + "idea": "Build an open-source tool to compare similar Python web frameworks", + "product_doc": "Need semantic search and recommendation quality scoring.", + }, + ) + assert resp.status_code == 200 + assert resp.headers.get("content-type", "").startswith("text/markdown") + body = resp.text + assert "# Idea Assessment Report" in body + assert "## Verdict" in body + + +@pytest.mark.asyncio +async def test_assess_idea_export_json(client: AsyncClient): + resp = await client.post( + "/api/v1/idea/assess/export?format=json", + json={ + "idea": "Build an open-source tool to compare similar Python web frameworks", + "product_doc": "Need semantic search and recommendation quality scoring.", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["report_type"] == "json" + assert "assessment" in data + assert data["assessment"]["verdict"] in { + "already_exists", + "similar_projects_exist", + "likely_novel", + } + + +@pytest.mark.asyncio +async def test_assess_idea_batch(client: AsyncClient): + resp = await client.post( + "/api/v1/idea/assess/batch", + json={ + "items": [ + { + "idea": "Open-source API mocking tool for integration tests", + "product_doc": "Need route templates and scenario replay.", + }, + { + "idea": "Collaborative PR review assistant for OSS maintainers", + "product_doc": "Need triage automation and contributor onboarding hints.", + }, + ], + "limit": 6, + "max_concurrency": 2, + "per_item_timeout_seconds": 30, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert len(data["results"]) == 2 + verdict_sum = ( + data["already_exists"] + + data["similar_projects_exist"] + + data["likely_novel"] + ) + assert verdict_sum == 2 + + +@pytest.mark.asyncio +async def test_assess_idea_batch_export_markdown(client: AsyncClient): + resp = await client.post( + "/api/v1/idea/assess/batch/export?format=markdown", + json={ + "items": [ + {"idea": "Open-source API mocking tool", "product_doc": "Need scenario replay"}, + {"idea": "PR review assistant for OSS maintainers", "product_doc": "Need triage automation"}, + ], + "limit": 6, + "max_concurrency": 2, + "per_item_timeout_seconds": 30, + }, + ) + assert resp.status_code == 200 + assert resp.headers.get("content-type", "").startswith("text/markdown") + body = resp.text + assert "# Batch Idea Assessment Report" in body + assert "## Summary" in body + + +@pytest.mark.asyncio +async def test_assess_idea_batch_export_json(client: AsyncClient): + resp = await client.post( + "/api/v1/idea/assess/batch/export?format=json", + json={ + "items": [ + {"idea": "Open-source API mocking tool", "product_doc": "Need scenario replay"}, + {"idea": "PR review assistant for OSS maintainers", "product_doc": "Need triage automation"}, + ], + "limit": 6, + "max_concurrency": 2, + "per_item_timeout_seconds": 30, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["report_type"] == "json" + assert "assessment_batch" in data + assert data["assessment_batch"]["total"] == 2 diff --git a/web/next.config.js b/web/next.config.js index c5db160..d81681e 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -1,11 +1,13 @@ /** @type {import('next').NextConfig} */ +const apiOrigin = process.env.OWLSCOPE_API_ORIGIN || "http://127.0.0.1:8010"; + const nextConfig = { // API proxy to backend async rewrites() { return [ { source: "/api/:path*", - destination: "http://localhost:8000/api/:path*", + destination: `${apiOrigin}/api/:path*`, }, ]; }, diff --git a/web/package.json b/web/package.json index bb94abf..dbf3f2e 100644 --- a/web/package.json +++ b/web/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev -H 127.0.0.1 -p 3100", "build": "next build", - "start": "next start", + "start": "next start -H 127.0.0.1 -p 3100", "lint": "next lint" }, "dependencies": { diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 2d24ac2..47cde86 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -2,20 +2,24 @@ @tailwind components; @tailwind utilities; -:root { - --foreground: #171717; - --background: #ffffff; -} +@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Fraunces:opsz,wght@9..144,600&display=swap"); -@media (prefers-color-scheme: dark) { - :root { - --foreground: #ededed; - --background: #0a0a0a; - } +:root { + --foreground: #382611; + --background: #fffaf3; } body { color: var(--foreground); background: var(--background); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: "Manrope", "Segoe UI", sans-serif; + background-image: + radial-gradient(circle at 16% 8%, #f5e8d2 0%, transparent 35%), + radial-gradient(circle at 85% 2%, #f6ecd9 0%, transparent 28%); +} + +h1, +h2, +h3 { + font-family: "Fraunces", Georgia, serif; } diff --git a/web/src/app/idea/page.tsx b/web/src/app/idea/page.tsx index 8e5d99e..73484f5 100644 --- a/web/src/app/idea/page.tsx +++ b/web/src/app/idea/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { assessIdea } from "@/lib/api"; +import { useEffect, useState } from "react"; +import { assessIdea, assessIdeaBatch, assessIdeaBatchExport } from "@/lib/api"; type Verdict = "already_exists" | "similar_projects_exist" | "likely_novel"; @@ -42,6 +42,34 @@ interface IdeaAssessResult { next_actions: string[]; } +interface IdeaAssessBatchResult { + total: number; + already_exists: number; + similar_projects_exist: number; + likely_novel: number; + results: IdeaAssessResult[]; +} + +interface BatchPayloadSnapshot { + items: Array<{ + idea: string; + product_doc?: string; + }>; + limit: number; + max_concurrency: number; + per_item_timeout_seconds: number; +} + +interface BatchExportHistoryEntry { + format: "markdown" | "json"; + itemCount: number; + fileName: string; + exportedAt: string; + payload?: BatchPayloadSnapshot; +} + +const BATCH_EXPORT_HISTORY_KEY = "owlscope.idea.batch_export_history"; + const VERDICT_UI: Record = { already_exists: { title: "Likely Already Implemented", @@ -65,6 +93,70 @@ export default function IdeaPage() { const [error, setError] = useState(""); const [copied, setCopied] = useState(false); const [result, setResult] = useState(null); + const [batchInput, setBatchInput] = useState(""); + const [batchLoading, setBatchLoading] = useState(false); + const [batchError, setBatchError] = useState(""); + const [batchResult, setBatchResult] = useState(null); + const [batchExportingFormat, setBatchExportingFormat] = useState<"markdown" | "json" | null>(null); + const [batchExportHistory, setBatchExportHistory] = useState([]); + const [historyLoaded, setHistoryLoaded] = useState(false); + const [maxConcurrency, setMaxConcurrency] = useState(2); + const [timeoutSeconds, setTimeoutSeconds] = useState(30); + + useEffect(() => { + try { + const raw = window.localStorage.getItem(BATCH_EXPORT_HISTORY_KEY); + if (!raw) { + setHistoryLoaded(true); + return; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + setHistoryLoaded(true); + return; + } + const normalized: BatchExportHistoryEntry[] = parsed + .filter((item) => item && typeof item === "object") + .map((item) => { + const rawPayload = item.payload; + const payload = rawPayload && Array.isArray(rawPayload.items) + ? { + items: rawPayload.items + .filter((entry: unknown) => !!entry && typeof entry === "object") + .map((entry: any) => ({ + idea: String(entry.idea || "").trim(), + product_doc: entry.product_doc ? String(entry.product_doc) : undefined, + })) + .filter((entry: { idea: string }) => entry.idea.length > 0) + .slice(0, 20), + limit: Number(rawPayload.limit) || 8, + max_concurrency: Number(rawPayload.max_concurrency) || 2, + per_item_timeout_seconds: Number(rawPayload.per_item_timeout_seconds) || 30, + } + : undefined; + + return { + format: (item.format === "json" ? "json" : "markdown") as "markdown" | "json", + itemCount: Number(item.itemCount) || 0, + fileName: String(item.fileName || ""), + exportedAt: String(item.exportedAt || ""), + payload: payload && payload.items.length > 0 ? payload : undefined, + }; + }) + .filter((item) => item.fileName.length > 0) + .slice(0, 5); + setBatchExportHistory(normalized); + } catch { + setBatchExportHistory([]); + } finally { + setHistoryLoaded(true); + } + }, []); + + useEffect(() => { + if (!historyLoaded) return; + window.localStorage.setItem(BATCH_EXPORT_HISTORY_KEY, JSON.stringify(batchExportHistory)); + }, [batchExportHistory, historyLoaded]); const buildMarkdownReport = () => { if (!result) return ""; @@ -142,6 +234,116 @@ export default function IdeaPage() { } }; + const parseBatchItems = () => { + return batchInput + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [ideaPart, docPart] = line.split("||", 2); + return { + idea: ideaPart.trim(), + product_doc: docPart?.trim() || undefined, + }; + }) + .filter((item) => item.idea.length > 0) + .slice(0, 20); + }; + + const runBatchAssessment = async () => { + const items = parseBatchItems(); + if (items.length === 0) return; + + setBatchLoading(true); + setBatchError(""); + setBatchResult(null); + + try { + const data = await assessIdeaBatch({ + items, + limit, + max_concurrency: maxConcurrency, + per_item_timeout_seconds: timeoutSeconds, + }); + setBatchResult(data); + } catch { + setBatchError("Failed to assess batch ideas. Please retry in a moment."); + } finally { + setBatchLoading(false); + } + }; + + const buildBatchPayloadFromCurrentInput = (): BatchPayloadSnapshot | null => { + const items = parseBatchItems(); + if (items.length === 0) return null; + return { + items, + limit, + max_concurrency: maxConcurrency, + per_item_timeout_seconds: timeoutSeconds, + }; + }; + + const downloadBatchReport = async ( + format: "markdown" | "json", + payloadOverride?: BatchPayloadSnapshot + ) => { + const payload = payloadOverride || buildBatchPayloadFromCurrentInput(); + if (!payload || payload.items.length === 0) return; + + setBatchExportingFormat(format); + setBatchError(""); + + try { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const exportedAt = new Date().toISOString(); + + const pushExportHistory = (fileName: string) => { + setBatchExportHistory((prev) => [ + { format, itemCount: payload.items.length, fileName, exportedAt, payload }, + ...prev, + ].slice(0, 5)); + }; + + if (format === "markdown") { + const report = (await assessIdeaBatchExport(payload, "markdown")) as string; + const blob = new Blob([report], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const fileName = `owlscope-idea-batch-report-${stamp}.md`; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + pushExportHistory(fileName); + return; + } + + const jsonReport = await assessIdeaBatchExport(payload, "json"); + const blob = new Blob([JSON.stringify(jsonReport, null, 2)], { type: "application/json;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const fileName = `owlscope-idea-batch-report-${stamp}.json`; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + pushExportHistory(fileName); + } catch { + setBatchError("Failed to export batch report. Please retry in a moment."); + } finally { + setBatchExportingFormat(null); + } + }; + + const clearBatchExportHistory = () => { + setBatchExportHistory([]); + }; + return (

💡 Idea Check

@@ -192,6 +394,66 @@ export default function IdeaPage() { {loading ? "Analyzing..." : "Assess Idea"} + +
+ +