diff --git a/.github/workflows/ai_review.yml b/.github/workflows/ai_review.yml index 6fcb661..9befb37 100644 --- a/.github/workflows/ai_review.yml +++ b/.github/workflows/ai_review.yml @@ -3,47 +3,106 @@ name: AI Monthly Review "on": issues: types: [opened] + workflow_dispatch: + inputs: + issue_number: + description: "Issue number to review" + required: true jobs: ai-review: - if: contains(github.event.issue.labels.*.name, 'monthly-review') + if: contains(github.event.issue.labels.*.name, 'monthly-review') || inputs.issue_number != '' runs-on: ubuntu-latest permissions: contents: read + id-token: write issues: write steps: - name: Checkout uses: actions/checkout@v5 + - name: Load review issue context + id: issue_context + run: | + python3 - <<'PY' + import json + import os + import urllib.request + + repo = os.environ["GITHUB_REPOSITORY"] + issue_number = os.environ["ISSUE_NUMBER"] + token = os.environ["GITHUB_TOKEN"] + api_url = f"https://api.github.com/repos/{repo}/issues/{issue_number}" + request = urllib.request.Request( + api_url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "binanceplatform-ai-review", + }, + ) + with urllib.request.urlopen(request) as response: + issue = json.load(response) + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + print("issue_title</dev/null || true + ISSUE_URL=$(gh issue create \ --title "Monthly Execution Review: ${MONTH}" \ --label "monthly-review" \ - --body-file data/output/ai_review_input.md + --body-file data/output/ai_review_input.md) + echo "issue_url=${ISSUE_URL}" >> "$GITHUB_OUTPUT" + echo "issue_number=${ISSUE_URL##*/}" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger AI monthly review + if: steps.fetch.outputs.has_data == 'true' + run: | + gh workflow run ai_review.yml \ + --ref "${GITHUB_REF_NAME}" \ + -f issue_number="${{ steps.issue.outputs.issue_number }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/post_monthly_ai_review_comment.py b/scripts/post_monthly_ai_review_comment.py new file mode 100644 index 0000000..5983c65 --- /dev/null +++ b/scripts/post_monthly_ai_review_comment.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + + +COMMENT_MARKER = "" +DEFAULT_API_URL = "https://api.github.com" + + +def extract_latest_assistant_text(execution_log: list[dict[str, Any]]) -> str: + for turn in reversed(execution_log): + if turn.get("type") != "assistant": + continue + + content_items = turn.get("message", {}).get("content", []) + text_parts = [ + item.get("text", "").strip() + for item in content_items + if item.get("type") == "text" and item.get("text", "").strip() + ] + if text_parts: + return "\n\n".join(text_parts).strip() + + raise ValueError("No assistant review text found in Claude execution log") + + +def build_comment_body(review_text: str, run_url: str | None = None) -> str: + body = f"{COMMENT_MARKER}\n## Claude Monthly Review\n\n{review_text.strip()}" + if run_url: + body += f"\n\n---\n_Generated by AI Monthly Review workflow: {run_url}_" + return body + + +def github_request( + method: str, + url: str, + token: str, + payload: dict[str, Any] | None = None, +) -> Any: + data = None + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "binanceplatform-monthly-ai-review", + } + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(request) as response: + charset = response.headers.get_content_charset("utf-8") + raw = response.read().decode(charset) + return json.loads(raw) if raw else None + + +def upsert_issue_comment( + *, + api_url: str, + repo: str, + issue_number: int, + token: str, + body: str, +) -> None: + comments_url = f"{api_url}/repos/{repo}/issues/{issue_number}/comments" + comments = github_request("GET", comments_url, token) + existing = next( + ( + comment + for comment in comments + if COMMENT_MARKER in comment.get("body", "") + ), + None, + ) + + if existing: + github_request( + "PATCH", + f"{api_url}/repos/{repo}/issues/comments/{existing['id']}", + token, + {"body": body}, + ) + print(f"Updated issue comment {existing['id']}") + return + + github_request("POST", comments_url, token, {"body": body}) + print(f"Created issue comment for issue #{issue_number}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Extract Claude review text from execution output and upsert an issue comment.", + ) + parser.add_argument("--repo", required=True, help="owner/repo") + parser.add_argument("--issue-number", required=True, type=int) + parser.add_argument("--execution-file", required=True, type=Path) + parser.add_argument("--api-url", default=DEFAULT_API_URL) + parser.add_argument("--run-url", default="") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("GITHUB_TOKEN is required", file=sys.stderr) + return 1 + + execution_log = json.loads(args.execution_file.read_text(encoding="utf-8")) + review_text = extract_latest_assistant_text(execution_log) + body = build_comment_body(review_text, args.run_url or None) + + try: + upsert_issue_comment( + api_url=args.api_url.rstrip("/"), + repo=args.repo, + issue_number=args.issue_number, + token=token, + body=body, + ) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + print(f"GitHub API request failed: {exc.code} {detail}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_monthly_report_bundle.py b/scripts/run_monthly_report_bundle.py index 079bcf1..2473cca 100644 --- a/scripts/run_monthly_report_bundle.py +++ b/scripts/run_monthly_report_bundle.py @@ -230,6 +230,16 @@ def format_review_markdown(bundle: dict[str, Any]) -> str: lines.append(f"_Generated: {generated}_") lines.append("") + # Scope / interpretation notes + lines.append("## Report Scope") + lines.append("") + lines.append("- This is BinancePlatform's downstream monthly execution review, not a pure upstream pool publication.") + lines.append("- It summarizes runtime health, recorded trade intents, earn buffer operations, circuit breaker activity, degraded mode, and upstream pool changes.") + lines.append("- Upstream pool changes are included as execution context from CryptoLeaderRotation, but they are only one input section of this report.") + lines.append("- Equity deltas in this report are raw month-start vs month-end snapshots and may include manual deposits, withdrawals, or other external balance flows.") + lines.append("- Trade and earn sections reflect execution intents/actions recorded in hourly reports, not a separate exchange fill reconciliation ledger.") + lines.append("") + # Run statistics lines.append("## Run Statistics") lines.append("") @@ -255,10 +265,14 @@ def format_review_markdown(bundle: dict[str, Any]) -> str: lines.append(f"| PnL (USDT) | {pnl['pnl_usdt']} |") lines.append(f"| PnL (%) | {pnl['pnl_pct']} |") lines.append("") + lines.append("> Note: Equity deltas may include external balance flows and should not be interpreted as pure strategy PnL without separate cash-flow reconciliation.") + lines.append("") # Trade summary lines.append("## Trade Summary") lines.append("") + lines.append("> Note: Trade counts below are based on recorded strategy intents in hourly execution reports, not exchange fill reconciliation.") + lines.append("") lines.append("### BTC Core (DCA)") lines.append("") lines.append("| Metric | Value |") @@ -348,11 +362,11 @@ def format_review_markdown(bundle: dict[str, Any]) -> str: # Review questions lines.append("## Review Questions") lines.append("") - lines.append("1. Is the PnL trend consistent with market conditions this month?") + lines.append("1. Does the equity trend look explainable once possible external deposits/withdrawals are considered?") lines.append("2. Were any circuit breaker events justified, or do thresholds need adjusting?") lines.append("3. Did upstream pool changes have a noticeable impact on performance?") lines.append("4. Are the failed runs isolated incidents or part of a pattern?") - lines.append("5. Should BTC DCA cadence or sizing be adjusted based on this month's data?") + lines.append("5. Do the recorded trade intents suggest BTC DCA cadence or trend sizing should be adjusted?") lines.append("6. Were earn buffer subscribe/redeem operations executed at appropriate times?") lines.append("") diff --git a/tests/test_monthly_report_bundle.py b/tests/test_monthly_report_bundle.py index 5a82dda..d7f1c51 100644 --- a/tests/test_monthly_report_bundle.py +++ b/tests/test_monthly_report_bundle.py @@ -137,3 +137,7 @@ def test_format_review_markdown(self): self.assertIn("Monthly Execution Review", md) self.assertIn("2026-03", md) + self.assertIn("downstream monthly execution review", md) + self.assertIn("not a pure upstream pool publication", md) + self.assertIn("external balance flows", md) + self.assertIn("recorded strategy intents", md) diff --git a/tests/test_monthly_report_workflow_config.py b/tests/test_monthly_report_workflow_config.py new file mode 100644 index 0000000..519546e --- /dev/null +++ b/tests/test_monthly_report_workflow_config.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +MONTHLY_REPORT_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "monthly_report.yml" +AI_REVIEW_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "ai_review.yml" + + +class MonthlyReportWorkflowConfigTests(unittest.TestCase): + def test_monthly_report_workflow_passes_hourly_dir_and_dispatches_ai_review(self) -> None: + workflow = MONTHLY_REPORT_WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("actions: write", workflow) + self.assertIn('--hourly-dir "hourly/${{ steps.month.outputs.month }}"', workflow) + self.assertIn("gh label create monthly-review", workflow) + self.assertIn("gh workflow run ai_review.yml", workflow) + self.assertIn('issue_number="${{ steps.issue.outputs.issue_number }}"', workflow) + + def test_ai_review_workflow_supports_manual_dispatch(self) -> None: + workflow = AI_REVIEW_WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("workflow_dispatch:", workflow) + self.assertIn("issue_number:", workflow) + self.assertIn("id-token: write", workflow) + self.assertIn("Load review issue context", workflow) + self.assertIn("api.github.com/repos", workflow) + self.assertIn("steps.issue_context.outputs.issue_title", workflow) + self.assertIn("steps.issue_context.outputs.issue_body", workflow) + self.assertIn("id: claude_review", workflow) + self.assertIn("github_token: ${{ secrets.GITHUB_TOKEN }}", workflow) + self.assertIn("${{ inputs.issue_number || github.event.issue.number }}", workflow) + self.assertIn("Do not use Bash or ask for additional approval.", workflow) + self.assertIn("The workflow will publish your final review as the issue comment.", workflow) + self.assertIn("This is a downstream execution review, not a pure upstream pool review.", workflow) + self.assertIn("do not treat equity delta as pure strategy PnL", workflow) + self.assertIn("not a separate exchange fill reconciliation", workflow) + self.assertIn("Do not assume zero trades are automatically anomalous", workflow) + self.assertIn("Treat a zero-trade month as context-dependent", workflow) + self.assertIn("post_monthly_ai_review_comment.py", workflow) + self.assertIn("steps.claude_review.outputs.execution_file", workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_post_monthly_ai_review_comment.py b/tests/test_post_monthly_ai_review_comment.py new file mode 100644 index 0000000..f6a0be8 --- /dev/null +++ b/tests/test_post_monthly_ai_review_comment.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import unittest + +from scripts.post_monthly_ai_review_comment import ( + COMMENT_MARKER, + build_comment_body, + extract_latest_assistant_text, +) + + +class PostMonthlyAiReviewCommentTests(unittest.TestCase): + def test_extract_latest_assistant_text_returns_last_text_reply(self) -> None: + execution_log = [ + {"type": "system", "subtype": "init"}, + { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "Working on it."}, + ] + }, + }, + { + "type": "assistant", + "message": { + "content": [ + {"type": "tool_use", "name": "Read", "input": {"file_path": "x"}}, + {"type": "text", "text": "## English\nFinal review\n\n## 中文\n最终结论"}, + ] + }, + }, + {"type": "result", "subtype": "success"}, + ] + + review_text = extract_latest_assistant_text(execution_log) + + self.assertEqual(review_text, "## English\nFinal review\n\n## 中文\n最终结论") + + def test_build_comment_body_includes_marker_and_run_link(self) -> None: + body = build_comment_body("Review content", "https://github.com/example/repo/actions/runs/1") + + self.assertIn(COMMENT_MARKER, body) + self.assertIn("## Claude Monthly Review", body) + self.assertIn("Review content", body) + self.assertIn("actions/runs/1", body) + + +if __name__ == "__main__": + unittest.main()